mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(assets): bundle runtime assets and vendor dependencies
This commit is contained in:
378
vendor/yomitan/js/pages/action-popup-main.js
vendored
Normal file
378
vendor/yomitan/js/pages/action-popup-main.js
vendored
Normal file
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2017-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ThemeController} from '../app/theme-controller.js';
|
||||
import {Application} from '../application.js';
|
||||
import {getAllPermissions, hasRequiredPermissionsForOptions} from '../data/permissions-util.js';
|
||||
import {HotkeyHelpController} from '../input/hotkey-help-controller.js';
|
||||
import {HotkeyUtil} from '../input/hotkey-util.js';
|
||||
|
||||
class DisplayController {
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
*/
|
||||
constructor(api) {
|
||||
/** @type {import('../comm/api.js').API} */
|
||||
this._api = api;
|
||||
/** @type {?import('settings').Options} */
|
||||
this._optionsFull = null;
|
||||
/** @type {ThemeController} */
|
||||
this._themeController = new ThemeController(document.documentElement);
|
||||
/** @type {HotkeyUtil} */
|
||||
this._hotkeyUtil = new HotkeyUtil();
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._themeController.prepare();
|
||||
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
|
||||
const {platform: {os}} = await this._api.getEnvironmentInfo();
|
||||
this._hotkeyUtil.os = os;
|
||||
|
||||
this._showExtensionInfo(manifest);
|
||||
void this._setupEnvironment();
|
||||
this._setupButtonEvents('.action-open-search', 'openSearchPage', chrome.runtime.getURL('/search.html'), this._onSearchClick.bind(this));
|
||||
this._setupButtonEvents('.action-open-info', 'openInfoPage', chrome.runtime.getURL('/info.html'));
|
||||
|
||||
const optionsFull = await this._api.optionsGetFull();
|
||||
this._optionsFull = optionsFull;
|
||||
|
||||
void this._setupHotkeys();
|
||||
|
||||
const optionsPageUrl = (
|
||||
typeof manifest.options_ui === 'object' &&
|
||||
manifest.options_ui !== null &&
|
||||
typeof manifest.options_ui.page === 'string' ?
|
||||
manifest.options_ui.page :
|
||||
''
|
||||
);
|
||||
this._setupButtonEvents('.action-open-settings', 'openSettingsPage', chrome.runtime.getURL(optionsPageUrl));
|
||||
|
||||
const {profiles, profileCurrent} = optionsFull;
|
||||
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
|
||||
if (defaultProfile !== null) {
|
||||
this._setupOptions(defaultProfile);
|
||||
}
|
||||
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const profileSelect = document.querySelectorAll('.action-select-profile');
|
||||
for (let i = 0; i < profileSelect.length; i++) {
|
||||
profileSelect[i].hidden = (profiles.length <= 1);
|
||||
}
|
||||
|
||||
this._updateProfileSelect(profiles, profileCurrent);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.dataset.loaded = 'true';
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_updateDisplayModifierKey() {
|
||||
const {profiles, profileCurrent} = /** @type {import('settings').Options} */ (this._optionsFull);
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const modifierKeyHint = document.querySelectorAll('.tooltip');
|
||||
|
||||
const currentModifierKey = profiles[profileCurrent].options.scanning.inputs[0].include;
|
||||
|
||||
/** @type {{ [key: string]: string }} */
|
||||
const modifierKeys = {};
|
||||
for (const value of /** @type {import('input').ModifierKey[]} */ (['alt', 'ctrl', 'shift', 'meta'])) {
|
||||
const name = this._hotkeyUtil.getModifierDisplayValue(value);
|
||||
modifierKeys[value] = name;
|
||||
}
|
||||
|
||||
for (let i = 0; i < modifierKeyHint.length; i++) {
|
||||
modifierKeyHint[i].textContent = currentModifierKey ? 'Hold ' : 'Hover over text to scan';
|
||||
if (currentModifierKey) {
|
||||
const em = document.createElement('em');
|
||||
em.textContent = modifierKeys[currentModifierKey];
|
||||
modifierKeyHint[i].appendChild(em);
|
||||
modifierKeyHint[i].appendChild(document.createTextNode(' to scan'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onSearchClick(e) {
|
||||
if (!e.shiftKey) { return; }
|
||||
e.preventDefault();
|
||||
location.href = '/search.html?action-popup=true';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.runtime.Manifest} manifest
|
||||
*/
|
||||
_showExtensionInfo(manifest) {
|
||||
const node = document.getElementById('extension-info');
|
||||
if (node === null) { return; }
|
||||
|
||||
node.textContent = `${manifest.name} v${manifest.version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @param {?string} command
|
||||
* @param {string} url
|
||||
* @param {(event: MouseEvent) => void} [customHandler]
|
||||
*/
|
||||
_setupButtonEvents(selector, command, url, customHandler) {
|
||||
/** @type {NodeListOf<HTMLAnchorElement>} */
|
||||
const nodes = document.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
if (typeof command === 'string') {
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
const onClick = (e) => {
|
||||
if (e.button !== 0) { return; }
|
||||
if (typeof customHandler === 'function') {
|
||||
const result = customHandler(e);
|
||||
if (typeof result !== 'undefined') { return; }
|
||||
}
|
||||
|
||||
let mode = 'existingOrNewTab';
|
||||
if (e.ctrlKey) {
|
||||
mode = 'newTab';
|
||||
} else if (e.shiftKey) {
|
||||
mode = 'popup';
|
||||
}
|
||||
|
||||
void this._api.commandExec(command, {mode: mode});
|
||||
e.preventDefault();
|
||||
};
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
const onAuxClick = (e) => {
|
||||
if (e.button !== 1) { return; }
|
||||
void this._api.commandExec(command, {mode: 'newTab'});
|
||||
e.preventDefault();
|
||||
};
|
||||
node.addEventListener('click', onClick, false);
|
||||
node.addEventListener('auxclick', onAuxClick, false);
|
||||
}
|
||||
|
||||
if (typeof url === 'string') {
|
||||
node.href = url;
|
||||
node.target = '_blank';
|
||||
node.rel = 'noopener';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _setupEnvironment() {
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
let mode = urlSearchParams.get('mode');
|
||||
switch (mode) {
|
||||
case 'full':
|
||||
case 'mini':
|
||||
break;
|
||||
default:
|
||||
{
|
||||
let tab;
|
||||
try {
|
||||
tab = await this._getCurrentTab();
|
||||
// Safari assigns a tab object to the popup, other browsers do not
|
||||
if (tab && await this._isSafari()) {
|
||||
tab = void 0;
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
mode = (tab ? 'full' : 'mini');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
document.documentElement.dataset.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<chrome.tabs.Tab|undefined>}
|
||||
*/
|
||||
_getCurrentTab() {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.getCurrent((result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').Profile} profile
|
||||
*/
|
||||
_setupOptions({options}) {
|
||||
const extensionEnabled = options.general.enable;
|
||||
const onToggleChanged = () => this._api.commandExec('toggleTextScanning');
|
||||
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.enable-search,.enable-search2'))) {
|
||||
if (toggle.checked !== extensionEnabled) {
|
||||
toggle.checked = extensionEnabled;
|
||||
}
|
||||
toggle.addEventListener('change', onToggleChanged, false);
|
||||
}
|
||||
void this._updateDisplayModifierKey();
|
||||
void this._updateDictionariesEnabledWarnings(options);
|
||||
void this._updatePermissionsWarnings(options);
|
||||
|
||||
this._themeController.theme = options.general.popupTheme;
|
||||
this._themeController.siteOverride = true;
|
||||
this._themeController.updateTheme();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _setupHotkeys() {
|
||||
const hotkeyHelpController = new HotkeyHelpController();
|
||||
await hotkeyHelpController.prepare(this._api);
|
||||
|
||||
const {profiles, profileCurrent} = /** @type {import('settings').Options} */ (this._optionsFull);
|
||||
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
|
||||
if (defaultProfile !== null) {
|
||||
hotkeyHelpController.setOptions(defaultProfile.options);
|
||||
}
|
||||
|
||||
hotkeyHelpController.setupNode(document.documentElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').Profile[]} profiles
|
||||
* @param {number} profileCurrent
|
||||
*/
|
||||
_updateProfileSelect(profiles, profileCurrent) {
|
||||
/** @type {NodeListOf<HTMLSelectElement>} */
|
||||
const selects = document.querySelectorAll('.profile-select');
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
for (let i = 0; i < Math.min(selects.length); i++) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let j = 0, jj = profiles.length; j < jj; ++j) {
|
||||
const {name} = profiles[j];
|
||||
const option = document.createElement('option');
|
||||
option.textContent = name;
|
||||
option.value = `${j}`;
|
||||
fragment.appendChild(option);
|
||||
}
|
||||
selects[i].textContent = '';
|
||||
selects[i].appendChild(fragment);
|
||||
selects[i].value = `${profileCurrent}`;
|
||||
|
||||
selects[i].addEventListener('change', this._onProfileSelectChange.bind(this), false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
_onProfileSelectChange(event) {
|
||||
const node = /** @type {HTMLInputElement} */ (event.currentTarget);
|
||||
const value = Number.parseInt(node.value, 10);
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < /** @type {import('settings').Options} */ (this._optionsFull).profiles.length) {
|
||||
const optionsFull = this._optionsFull;
|
||||
if (optionsFull && value < optionsFull.profiles.length) {
|
||||
void this._setDefaultProfileIndex(value);
|
||||
optionsFull.profileCurrent = value;
|
||||
const defaultProfile = optionsFull.profiles[optionsFull.profileCurrent];
|
||||
if (defaultProfile !== null) {
|
||||
this._setupOptions(defaultProfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*/
|
||||
async _setDefaultProfileIndex(value) {
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'profileCurrent',
|
||||
value,
|
||||
scope: 'global',
|
||||
optionsContext: null,
|
||||
};
|
||||
await this._api.modifySettings([modification], 'action-popup');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
*/
|
||||
async _updateDictionariesEnabledWarnings(options) {
|
||||
const tooltip = document.querySelectorAll('.tooltip');
|
||||
const dictionaries = await this._api.getDictionaryInfo();
|
||||
|
||||
const enabledDictionaries = new Set();
|
||||
for (const {name, enabled} of options.dictionaries) {
|
||||
if (enabled) {
|
||||
enabledDictionaries.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
let enabledCount = 0;
|
||||
for (const {title} of dictionaries) {
|
||||
if (enabledDictionaries.has(title)) {
|
||||
++enabledCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledCount === 0) {
|
||||
for (let i = 0; i < tooltip.length; i++) {
|
||||
tooltip[i].innerHTML = 'No dictionary enabled';
|
||||
tooltip[i].classList.add('enable-dictionary-tooltip');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
*/
|
||||
async _updatePermissionsWarnings(options) {
|
||||
const permissions = await getAllPermissions();
|
||||
if (hasRequiredPermissionsForOptions(permissions, options)) { return; }
|
||||
|
||||
const tooltip = document.querySelectorAll('.tooltip');
|
||||
for (let i = 0; i < tooltip.length; i++) {
|
||||
tooltip[i].innerHTML = '<a class="action-open-permissions">Please enable permissions</a>';
|
||||
}
|
||||
this._setupButtonEvents('.action-open-permissions', null, chrome.runtime.getURL('/permissions.html'));
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} */
|
||||
async _isSafari() {
|
||||
const {browser} = await this._api.getEnvironmentInfo();
|
||||
return browser === 'safari';
|
||||
}
|
||||
}
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
void application.api.logIndicatorClear();
|
||||
|
||||
const displayController = new DisplayController(application.api);
|
||||
await displayController.prepare();
|
||||
});
|
||||
154
vendor/yomitan/js/pages/common/extension-content-controller.js
vendored
Normal file
154
vendor/yomitan/js/pages/common/extension-content-controller.js
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Environment} from '../../extension/environment.js';
|
||||
|
||||
export class ExtensionContentController {
|
||||
/** */
|
||||
prepare() {
|
||||
this._prepareSpecialUrls();
|
||||
this._prepareExtensionIdExamples();
|
||||
void this._prepareEnvironmentInfo();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _prepareEnvironmentInfo() {
|
||||
const {dataset} = document.documentElement;
|
||||
const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
|
||||
dataset.manifestVersion = `${manifestVersion}`;
|
||||
|
||||
const environment = new Environment();
|
||||
await environment.prepare();
|
||||
|
||||
const {browser, platform} = environment.getInfo();
|
||||
dataset.browser = browser;
|
||||
dataset.os = platform.os;
|
||||
}
|
||||
|
||||
/** */
|
||||
_prepareExtensionIdExamples() {
|
||||
const nodes = document.querySelectorAll('.extension-id-example');
|
||||
let url = '';
|
||||
try {
|
||||
url = chrome.runtime.getURL('/');
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
for (const node of nodes) {
|
||||
node.textContent = url;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_prepareSpecialUrls() {
|
||||
const nodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-special-url]'));
|
||||
if (nodes.length === 0) { return; }
|
||||
|
||||
let extensionId = '';
|
||||
try {
|
||||
extensionId = chrome.runtime.id;
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
const idPattern = /\{id\}/g;
|
||||
const onSpecialUrlLinkClick = this._onSpecialUrlLinkClick.bind(this);
|
||||
const onSpecialUrlLinkMouseDown = this._onSpecialUrlLinkMouseDown.bind(this);
|
||||
for (const node of nodes) {
|
||||
let {specialUrl} = node.dataset;
|
||||
if (typeof specialUrl !== 'string') { specialUrl = ''; }
|
||||
node.dataset.specialUrl = specialUrl.replace(idPattern, extensionId);
|
||||
node.addEventListener('click', onSpecialUrlLinkClick, false);
|
||||
node.addEventListener('auxclick', onSpecialUrlLinkClick, false);
|
||||
node.addEventListener('mousedown', onSpecialUrlLinkMouseDown, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onSpecialUrlLinkClick(e) {
|
||||
switch (e.button) {
|
||||
case 0:
|
||||
case 1:
|
||||
{
|
||||
const element = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const {specialUrl} = element.dataset;
|
||||
if (typeof specialUrl !== 'string') { return; }
|
||||
e.preventDefault();
|
||||
void this._createTab(specialUrl, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onSpecialUrlLinkMouseDown(e) {
|
||||
switch (e.button) {
|
||||
case 0:
|
||||
case 1:
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {boolean} useOpener
|
||||
* @returns {Promise<chrome.tabs.Tab>}
|
||||
*/
|
||||
async _createTab(url, useOpener) {
|
||||
/** @type {number|undefined} */
|
||||
let openerTabId;
|
||||
if (useOpener) {
|
||||
try {
|
||||
/** @type {chrome.tabs.Tab|undefined} */
|
||||
const tab = await new Promise((resolve, reject) => {
|
||||
chrome.tabs.getCurrent((result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (typeof tab !== 'undefined') {
|
||||
openerTabId = tab.id;
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
chrome.tabs.create({url, openerTabId}, (tab2) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(tab2);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
48
vendor/yomitan/js/pages/generic-page-main.js
vendored
Normal file
48
vendor/yomitan/js/pages/generic-page-main.js
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ThemeController} from '../app/theme-controller.js';
|
||||
import {Application} from '../application.js';
|
||||
import {DocumentFocusController} from '../dom/document-focus-controller.js';
|
||||
import {ExtensionContentController} from './common/extension-content-controller.js';
|
||||
import {SettingsController} from './settings/settings-controller.js';
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const settingsController = new SettingsController(application);
|
||||
await settingsController.prepare();
|
||||
/** @type {ThemeController} */
|
||||
const themeController = new ThemeController(document.documentElement);
|
||||
themeController.prepare();
|
||||
const optionsFull = await application.api.optionsGetFull();
|
||||
const {profiles, profileCurrent} = optionsFull;
|
||||
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
|
||||
if (defaultProfile !== null) {
|
||||
themeController.theme = defaultProfile.options.general.popupTheme;
|
||||
themeController.siteOverride = true;
|
||||
themeController.updateTheme();
|
||||
}
|
||||
document.body.hidden = false;
|
||||
|
||||
const documentFocusController = new DocumentFocusController();
|
||||
documentFocusController.prepare();
|
||||
|
||||
const extensionContentController = new ExtensionContentController();
|
||||
extensionContentController.prepare();
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
178
vendor/yomitan/js/pages/info-main.js
vendored
Normal file
178
vendor/yomitan/js/pages/info-main.js
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ThemeController} from '../app/theme-controller.js';
|
||||
import {Application} from '../application.js';
|
||||
import {promiseTimeout} from '../core/utilities.js';
|
||||
import {DocumentFocusController} from '../dom/document-focus-controller.js';
|
||||
import {querySelectorNotNull} from '../dom/query-selector.js';
|
||||
import {BackupController} from './settings/backup-controller.js';
|
||||
import {SettingsController} from './settings/settings-controller.js';
|
||||
|
||||
/**
|
||||
* @param {import('environment').Browser} browser
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBrowserDisplayName(browser) {
|
||||
switch (browser) {
|
||||
case 'chrome': return 'Chrome';
|
||||
case 'firefox': return 'Firefox';
|
||||
case 'firefox-mobile': return 'Firefox for Android';
|
||||
case 'edge': return 'Edge';
|
||||
case 'edge-legacy': return 'Edge Legacy';
|
||||
case 'safari': return 'Safari';
|
||||
default: return `${browser}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('environment').OperatingSystem} os
|
||||
* @returns {string}
|
||||
*/
|
||||
function getOperatingSystemDisplayName(os) {
|
||||
switch (os) {
|
||||
case 'mac': return 'Mac OS';
|
||||
case 'win': return 'Windows';
|
||||
case 'android': return 'Android';
|
||||
case 'cros': return 'Chrome OS';
|
||||
case 'linux': return 'Linux';
|
||||
case 'openbsd': return 'Open BSD';
|
||||
case 'unknown': return 'Unknown';
|
||||
default: return `${os}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
*/
|
||||
async function showAnkiConnectInfo(api) {
|
||||
let ankiConnectVersion = null;
|
||||
try {
|
||||
ankiConnectVersion = await api.getAnkiConnectVersion();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const ankiVersionElement = querySelectorNotNull(document, '#anki-connect-version');
|
||||
/** @type {HTMLElement} */
|
||||
const ankiVersionContainerElement = querySelectorNotNull(document, '#anki-connect-version-container');
|
||||
/** @type {HTMLElement} */
|
||||
const ankiVersionUnknownElement = querySelectorNotNull(document, '#anki-connect-version-unknown-message');
|
||||
|
||||
ankiVersionElement.textContent = (ankiConnectVersion !== null ? `${ankiConnectVersion}` : 'Unknown');
|
||||
ankiVersionContainerElement.dataset.hasError = `${ankiConnectVersion === null}`;
|
||||
ankiVersionUnknownElement.hidden = (ankiConnectVersion !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
*/
|
||||
async function showDictionaryInfo(api) {
|
||||
let dictionaryInfos;
|
||||
try {
|
||||
dictionaryInfos = await api.getDictionaryInfo();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
let first = true;
|
||||
for (const {title} of dictionaryInfos) {
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(', '));
|
||||
}
|
||||
|
||||
const node = document.createElement('span');
|
||||
node.className = 'installed-dictionary';
|
||||
node.textContent = title;
|
||||
fragment.appendChild(node);
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const noneElement = querySelectorNotNull(document, '#installed-dictionaries-none');
|
||||
|
||||
noneElement.hidden = (dictionaryInfos.length > 0);
|
||||
/** @type {HTMLElement} */
|
||||
const container = querySelectorNotNull(document, '#installed-dictionaries');
|
||||
container.textContent = '';
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const settingsController = new SettingsController(application);
|
||||
await settingsController.prepare();
|
||||
|
||||
/** @type {ThemeController} */
|
||||
const themeController = new ThemeController(document.documentElement);
|
||||
themeController.prepare();
|
||||
const optionsFull = await application.api.optionsGetFull();
|
||||
const {profiles, profileCurrent} = optionsFull;
|
||||
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
|
||||
if (defaultProfile !== null) {
|
||||
themeController.theme = defaultProfile.options.general.popupTheme;
|
||||
themeController.siteOverride = true;
|
||||
themeController.updateTheme();
|
||||
}
|
||||
|
||||
document.body.hidden = false;
|
||||
|
||||
const documentFocusController = new DocumentFocusController();
|
||||
documentFocusController.prepare();
|
||||
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const language = chrome.i18n.getUILanguage();
|
||||
|
||||
const {userAgent} = navigator;
|
||||
const {name, version} = manifest;
|
||||
const {browser, platform: {os}} = await application.api.getEnvironmentInfo();
|
||||
|
||||
/** @type {HTMLLinkElement} */
|
||||
const thisVersionLink = querySelectorNotNull(document, '#release-notes-this-version-link');
|
||||
const {hrefFormat} = thisVersionLink.dataset;
|
||||
thisVersionLink.href = typeof hrefFormat === 'string' ? hrefFormat.replace(/\{version\}/g, version) : '';
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const versionElement = querySelectorNotNull(document, '#version');
|
||||
/** @type {HTMLElement} */
|
||||
const browserElement = querySelectorNotNull(document, '#browser');
|
||||
/** @type {HTMLElement} */
|
||||
const platformElement = querySelectorNotNull(document, '#platform');
|
||||
/** @type {HTMLElement} */
|
||||
const languageElement = querySelectorNotNull(document, '#language');
|
||||
/** @type {HTMLElement} */
|
||||
const userAgentElement = querySelectorNotNull(document, '#user-agent');
|
||||
|
||||
versionElement.textContent = `${name} ${version}`;
|
||||
browserElement.textContent = getBrowserDisplayName(browser);
|
||||
platformElement.textContent = getOperatingSystemDisplayName(os);
|
||||
languageElement.textContent = `${language}`;
|
||||
userAgentElement.textContent = userAgent;
|
||||
|
||||
void showAnkiConnectInfo(application.api);
|
||||
void showDictionaryInfo(application.api);
|
||||
|
||||
const backupController = new BackupController(settingsController, null);
|
||||
await backupController.prepare();
|
||||
|
||||
await promiseTimeout(100);
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
139
vendor/yomitan/js/pages/permissions-main.js
vendored
Normal file
139
vendor/yomitan/js/pages/permissions-main.js
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Application} from '../application.js';
|
||||
import {promiseTimeout} from '../core/utilities.js';
|
||||
import {DocumentFocusController} from '../dom/document-focus-controller.js';
|
||||
import {querySelectorNotNull} from '../dom/query-selector.js';
|
||||
import {ExtensionContentController} from './common/extension-content-controller.js';
|
||||
import {ModalController} from './settings/modal-controller.js';
|
||||
import {PermissionsOriginController} from './settings/permissions-origin-controller.js';
|
||||
import {PermissionsToggleController} from './settings/permissions-toggle-controller.js';
|
||||
import {PersistentStorageController} from './settings/persistent-storage-controller.js';
|
||||
import {SettingsController} from './settings/settings-controller.js';
|
||||
import {SettingsDisplayController} from './settings/settings-display-controller.js';
|
||||
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
*/
|
||||
async function setupEnvironmentInfo(api) {
|
||||
const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
|
||||
const {browser, platform} = await api.getEnvironmentInfo();
|
||||
document.documentElement.dataset.browser = browser;
|
||||
document.documentElement.dataset.os = platform.os;
|
||||
document.documentElement.dataset.manifestVersion = `${manifestVersion}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function isAllowedIncognitoAccess() {
|
||||
return await new Promise((resolve) => { chrome.extension.isAllowedIncognitoAccess(resolve); });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function isAllowedFileSchemeAccess() {
|
||||
return await new Promise((resolve) => { chrome.extension.isAllowedFileSchemeAccess(resolve); });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function setupPermissionsToggles() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const optionalPermissions = manifest.optional_permissions;
|
||||
/** @type {Set<string>} */
|
||||
const optionalPermissionsSet = new Set(optionalPermissions);
|
||||
if (Array.isArray(optionalPermissions)) {
|
||||
for (const permission of optionalPermissions) {
|
||||
optionalPermissionsSet.add(permission);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string>} set
|
||||
* @param {string[]} values
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const hasAllPermisions = (set, values) => {
|
||||
for (const value of values) {
|
||||
if (!set.has(value)) { return false; }
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.permissions-toggle'))) {
|
||||
const permissions = toggle.dataset.requiredPermissions;
|
||||
const permissionsArray = (typeof permissions === 'string' && permissions.length > 0 ? permissions.split(' ') : []);
|
||||
toggle.disabled = !hasAllPermisions(optionalPermissionsSet, permissionsArray);
|
||||
}
|
||||
}
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const modalController = new ModalController([]);
|
||||
await modalController.prepare();
|
||||
|
||||
const settingsController = new SettingsController(application);
|
||||
await settingsController.prepare();
|
||||
|
||||
const settingsDisplayController = new SettingsDisplayController(settingsController, modalController);
|
||||
await settingsDisplayController.prepare();
|
||||
|
||||
document.body.hidden = false;
|
||||
|
||||
const documentFocusController = new DocumentFocusController();
|
||||
documentFocusController.prepare();
|
||||
|
||||
const extensionContentController = new ExtensionContentController();
|
||||
extensionContentController.prepare();
|
||||
|
||||
setupPermissionsToggles();
|
||||
|
||||
void setupEnvironmentInfo(application.api);
|
||||
|
||||
/** @type {HTMLInputElement} */
|
||||
const permissionCheckbox1 = querySelectorNotNull(document, '#permission-checkbox-allow-in-private-windows');
|
||||
/** @type {HTMLInputElement} */
|
||||
const permissionCheckbox2 = querySelectorNotNull(document, '#permission-checkbox-allow-file-url-access');
|
||||
/** @type {HTMLInputElement[]} */
|
||||
const permissionsCheckboxes = [permissionCheckbox1, permissionCheckbox2];
|
||||
|
||||
const permissions = await Promise.all([
|
||||
isAllowedIncognitoAccess(),
|
||||
isAllowedFileSchemeAccess(),
|
||||
]);
|
||||
|
||||
for (let i = 0, ii = permissions.length; i < ii; ++i) {
|
||||
permissionsCheckboxes[i].checked = permissions[i];
|
||||
}
|
||||
|
||||
const permissionsToggleController = new PermissionsToggleController(settingsController);
|
||||
void permissionsToggleController.prepare();
|
||||
|
||||
const permissionsOriginController = new PermissionsOriginController(settingsController);
|
||||
void permissionsOriginController.prepare();
|
||||
|
||||
const persistentStorageController = new PersistentStorageController(application);
|
||||
void persistentStorageController.prepare();
|
||||
|
||||
await promiseTimeout(100);
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
1436
vendor/yomitan/js/pages/settings/anki-controller.js
vendored
Normal file
1436
vendor/yomitan/js/pages/settings/anki-controller.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
667
vendor/yomitan/js/pages/settings/anki-deck-generator-controller.js
vendored
Normal file
667
vendor/yomitan/js/pages/settings/anki-deck-generator-controller.js
vendored
Normal file
@@ -0,0 +1,667 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ExtensionError} from '../../core/extension-error.js';
|
||||
import {log} from '../../core/log.js';
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {AnkiNoteBuilder} from '../../data/anki-note-builder.js';
|
||||
import {getDynamicTemplates} from '../../data/anki-template-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {getLanguageSummaries} from '../../language/languages.js';
|
||||
import {getRequiredAudioSources} from '../../media/audio-downloader.js';
|
||||
import {TemplateRendererProxy} from '../../templates/template-renderer-proxy.js';
|
||||
|
||||
export class AnkiDeckGeneratorController {
|
||||
/**
|
||||
* @param {import('../../application.js').Application} application
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {import('./modal-controller.js').ModalController} modalController
|
||||
* @param {import('./anki-controller.js').AnkiController} ankiController
|
||||
*/
|
||||
constructor(application, settingsController, modalController, ankiController) {
|
||||
/** @type {import('../../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {import('./modal-controller.js').ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {import('./anki-controller.js').AnkiController} */
|
||||
this._ankiController = ankiController;
|
||||
/** @type {?string} */
|
||||
this._defaultFieldTemplates = null;
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
this._mainSettingsEntry = querySelectorNotNull(document, '#generate-anki-notes-main-settings-entry');
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
this._wordInputTextarea = querySelectorNotNull(document, '#generate-anki-notes-textarea');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._renderTextInput = querySelectorNotNull(document, '#generate-anki-notes-test-text-input');
|
||||
/** @type {HTMLElement} */
|
||||
this._renderResult = querySelectorNotNull(document, '#generate-anki-notes-render-result');
|
||||
/** @type {HTMLElement} */
|
||||
this._activeModelText = querySelectorNotNull(document, '#generate-anki-notes-active-model');
|
||||
/** @type {HTMLElement} */
|
||||
this._activeDeckText = querySelectorNotNull(document, '#generate-anki-notes-active-deck');
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._activeFlashcardFormatSelect = querySelectorNotNull(document, '#generate-anki-flashcard-format');
|
||||
/** @type {import('settings').AnkiCardFormat[]} */
|
||||
this._flashcardFormatDetails = [];
|
||||
/** @type {HTMLInputElement} */
|
||||
this._addMediaCheckbox = querySelectorNotNull(document, '#generate-anki-notes-add-media');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._disallowDuplicatesCheckbox = querySelectorNotNull(document, '#generate-anki-notes-disallow-duplicates');
|
||||
/** @type {string} */
|
||||
this._activeNoteType = '';
|
||||
/** @type {string} */
|
||||
this._activeAnkiDeck = '';
|
||||
/** @type {HTMLSpanElement} */
|
||||
this._sendWordcount = querySelectorNotNull(document, '#generate-anki-notes-send-wordcount');
|
||||
/** @type {HTMLSpanElement} */
|
||||
this._exportWordcount = querySelectorNotNull(document, '#generate-anki-notes-export-wordcount');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._sendToAnkiButtonConfirmButton = querySelectorNotNull(document, '#generate-anki-notes-send-button-confirm');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._exportButtonConfirmButton = querySelectorNotNull(document, '#generate-anki-notes-export-button-confirm');
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
this._progressContainers = (document.querySelectorAll('.generate-anki-notes-progress'));
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._sendToAnkiConfirmModal = null;
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._exportConfirmModal = null;
|
||||
/** @type {boolean} */
|
||||
this._cancel = false;
|
||||
/** @type {boolean} */
|
||||
this._inProgress = false;
|
||||
/** @type {AnkiNoteBuilder} */
|
||||
this._ankiNoteBuilder = new AnkiNoteBuilder(settingsController.application.api, new TemplateRendererProxy());
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._defaultFieldTemplates = await this._settingsController.application.api.getDefaultAnkiFieldTemplates();
|
||||
|
||||
/** @type {HTMLButtonElement} */
|
||||
const parseWordsButton = querySelectorNotNull(document, '#generate-anki-notes-parse-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const dedupeWordsButton = querySelectorNotNull(document, '#generate-anki-notes-dedupe-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const testRenderButton = querySelectorNotNull(document, '#generate-anki-notes-test-render-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const sendToAnkiButton = querySelectorNotNull(document, '#generate-anki-notes-send-to-anki-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const sendToAnkiCancelButton = querySelectorNotNull(document, '#generate-anki-notes-send-to-anki-cancel-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const exportButton = querySelectorNotNull(document, '#generate-anki-notes-export-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const exportCancelButton = querySelectorNotNull(document, '#generate-anki-notes-export-cancel-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const generateButton = querySelectorNotNull(document, '#generate-anki-notes-export-button');
|
||||
|
||||
this._sendToAnkiConfirmModal = this._modalController.getModal('generate-anki-notes-send-to-anki');
|
||||
this._exportConfirmModal = this._modalController.getModal('generate-anki-notes-export');
|
||||
|
||||
parseWordsButton.addEventListener('click', this._onParse.bind(this), false);
|
||||
dedupeWordsButton.addEventListener('click', this._onDedupe.bind(this), false);
|
||||
testRenderButton.addEventListener('click', this._onRender.bind(this), false);
|
||||
sendToAnkiButton.addEventListener('click', this._onSendToAnki.bind(this), false);
|
||||
this._sendToAnkiButtonConfirmButton.addEventListener('click', this._onSendToAnkiConfirm.bind(this), false);
|
||||
sendToAnkiCancelButton.addEventListener('click', (() => { this._cancel = true; }).bind(this), false);
|
||||
exportButton.addEventListener('click', this._onExport.bind(this), false);
|
||||
this._exportButtonConfirmButton.addEventListener('click', this._onExportConfirm.bind(this), false);
|
||||
exportCancelButton.addEventListener('click', (() => { this._cancel = true; }).bind(this), false);
|
||||
generateButton.addEventListener('click', this._onExport.bind(this), false);
|
||||
|
||||
void this._updateExampleText();
|
||||
this._mainSettingsEntry.addEventListener('click', this._updateExampleText.bind(this), false);
|
||||
|
||||
void this._setupModelSelection();
|
||||
this._mainSettingsEntry.addEventListener('click', this._setupModelSelection.bind(this), false);
|
||||
|
||||
this._activeFlashcardFormatSelect.addEventListener('change', this._updateActiveModel.bind(this), false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _onParse() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
const parserResult = await this._application.api.parseText(this._wordInputTextarea.value, optionsContext, options.scanning.length, !options.parsing.enableMecabParser, options.parsing.enableMecabParser);
|
||||
const parsedText = parserResult[0].content;
|
||||
|
||||
const parsedParts = [];
|
||||
for (const parsedTextLine of parsedText) {
|
||||
let combinedSegments = '';
|
||||
for (const parsedTextSegment of parsedTextLine) {
|
||||
combinedSegments += parsedTextSegment.text;
|
||||
}
|
||||
combinedSegments = combinedSegments.trim();
|
||||
if (combinedSegments.length > 0) {
|
||||
parsedParts.push(combinedSegments);
|
||||
}
|
||||
}
|
||||
this._wordInputTextarea.value = parsedParts.join('\n');
|
||||
}
|
||||
|
||||
/** */
|
||||
_onDedupe() {
|
||||
this._wordInputTextarea.value = [...new Set(this._wordInputTextarea.value.split('\n'))].join('\n');
|
||||
}
|
||||
|
||||
/** */
|
||||
async _setupModelSelection() {
|
||||
const activeFlashcardFormat = /** @type {HTMLSelectElement} */ (this._activeFlashcardFormatSelect);
|
||||
const options = await this._settingsController.getOptions();
|
||||
this._flashcardFormatDetails = options.anki.cardFormats;
|
||||
|
||||
activeFlashcardFormat.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < options.anki.cardFormats.length; i++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = i.toString();
|
||||
option.text = options.anki.cardFormats[i].name;
|
||||
activeFlashcardFormat.add(option);
|
||||
}
|
||||
|
||||
void this._updateActiveModel();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateActiveModel() {
|
||||
const activeModelText = /** @type {HTMLElement} */ (this._activeModelText);
|
||||
const activeDeckText = /** @type {HTMLElement} */ (this._activeDeckText);
|
||||
const activeDeckTextConfirm = querySelectorNotNull(document, '#generate-anki-notes-active-deck-confirm');
|
||||
|
||||
const index = Number(this._activeFlashcardFormatSelect.value);
|
||||
|
||||
this._activeNoteType = this._flashcardFormatDetails[index].model;
|
||||
this._activeAnkiDeck = this._flashcardFormatDetails[index].deck;
|
||||
activeModelText.textContent = this._activeNoteType;
|
||||
activeDeckText.textContent = this._activeAnkiDeck;
|
||||
activeDeckTextConfirm.textContent = this._activeAnkiDeck;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _resetState() {
|
||||
this._updateProgressBar(true, '', 0, 1, false);
|
||||
this._cancel = false;
|
||||
|
||||
this._exportButtonConfirmButton.disabled = false;
|
||||
this._exportWordcount.textContent = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n').filter(Boolean).length.toString();
|
||||
|
||||
this._sendToAnkiButtonConfirmButton.disabled = false;
|
||||
this._addMediaCheckbox.disabled = false;
|
||||
this._disallowDuplicatesCheckbox.disabled = false;
|
||||
this._sendWordcount.textContent = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n').filter(Boolean).length.toString();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _startGenerationState() {
|
||||
this._inProgress = true;
|
||||
|
||||
this._exportButtonConfirmButton.disabled = true;
|
||||
|
||||
this._sendToAnkiButtonConfirmButton.disabled = true;
|
||||
this._addMediaCheckbox.disabled = true;
|
||||
this._disallowDuplicatesCheckbox.disabled = true;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _endGenerationState() {
|
||||
this._inProgress = false;
|
||||
|
||||
if (this._exportConfirmModal !== null) {
|
||||
this._exportConfirmModal.setVisible(false);
|
||||
}
|
||||
|
||||
if (this._sendToAnkiConfirmModal !== null) {
|
||||
this._sendToAnkiConfirmModal.setVisible(false);
|
||||
}
|
||||
|
||||
this._updateProgressBar(false, '', 1, 1, false);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _endGenerationStateError() {
|
||||
this._inProgress = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onExport(e) {
|
||||
e.preventDefault();
|
||||
if (this._exportConfirmModal !== null) {
|
||||
this._exportConfirmModal.setVisible(true);
|
||||
if (this._inProgress) { return; }
|
||||
void this._resetState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
async _onExportConfirm(e) {
|
||||
e.preventDefault();
|
||||
void this._startGenerationState();
|
||||
const terms = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n');
|
||||
let ankiTSV = '#separator:tab\n#html:true\n#notetype column:1\n#deck column:2\n#tags column:3\n';
|
||||
let index = 0;
|
||||
requestAnimationFrame(() => {
|
||||
this._updateProgressBar(true, 'Exporting to File...', 0, terms.length, true);
|
||||
setTimeout(async () => {
|
||||
for (const value of terms) {
|
||||
if (!value) { continue; }
|
||||
if (this._cancel) {
|
||||
void this._endGenerationState();
|
||||
return;
|
||||
}
|
||||
const noteData = await this._generateNoteData(value, false);
|
||||
if (noteData !== null) {
|
||||
const fieldsTSV = this._fieldsToTSV(noteData.fields);
|
||||
if (fieldsTSV) {
|
||||
ankiTSV += this._activeNoteType + '\t';
|
||||
ankiTSV += this._activeAnkiDeck + '\t';
|
||||
ankiTSV += noteData.tags.join(' ') + '\t';
|
||||
ankiTSV += fieldsTSV;
|
||||
ankiTSV += '\n';
|
||||
}
|
||||
}
|
||||
index++;
|
||||
this._updateProgressBar(false, '', index, terms.length, true);
|
||||
}
|
||||
const today = new Date();
|
||||
const fileName = 'anki-deck-' + today.toISOString().split('.')[0].replaceAll(/(T|:)/g, '-') + '.txt';
|
||||
const blob = new Blob([ankiTSV], {type: 'application/octet-stream'});
|
||||
this._saveBlob(blob, fileName);
|
||||
|
||||
void this._endGenerationState();
|
||||
}, 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onSendToAnki(e) {
|
||||
e.preventDefault();
|
||||
if (this._sendToAnkiConfirmModal !== null) {
|
||||
this._sendToAnkiConfirmModal.setVisible(true);
|
||||
if (this._inProgress) { return; }
|
||||
void this._resetState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
async _onSendToAnkiConfirm(e) {
|
||||
e.preventDefault();
|
||||
void this._startGenerationState();
|
||||
const terms = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n');
|
||||
const addMedia = this._addMediaCheckbox.checked;
|
||||
const disallowDuplicates = this._disallowDuplicatesCheckbox.checked;
|
||||
/** @type {import("anki.js").Note[]} */
|
||||
let notes = [];
|
||||
let index = 0;
|
||||
requestAnimationFrame(() => {
|
||||
this._updateProgressBar(true, 'Sending to Anki...', 0, terms.length, true);
|
||||
setTimeout(async () => {
|
||||
for (const value of terms) {
|
||||
if (!value) { continue; }
|
||||
if (this._cancel) {
|
||||
void this._endGenerationState();
|
||||
return;
|
||||
}
|
||||
const noteData = await this._generateNoteData(value, addMedia);
|
||||
if (noteData) {
|
||||
notes.push(noteData);
|
||||
}
|
||||
if (notes.length >= 100) {
|
||||
const sendNotesResult = await this._sendNotes(notes, disallowDuplicates);
|
||||
if (sendNotesResult === false) {
|
||||
void this._endGenerationStateError();
|
||||
return;
|
||||
}
|
||||
notes = [];
|
||||
}
|
||||
index++;
|
||||
this._updateProgressBar(false, '', index, terms.length, true);
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
const sendNotesResult = await this._sendNotes(notes, disallowDuplicates);
|
||||
if (sendNotesResult === false) {
|
||||
void this._endGenerationStateError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void this._endGenerationState();
|
||||
}, 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("anki.js").Note[]} notes
|
||||
* @param {boolean} disallowDuplicates
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _sendNotes(notes, disallowDuplicates) {
|
||||
try {
|
||||
if (disallowDuplicates) {
|
||||
const duplicateNotes = await this._ankiController.canAddNotes(notes.map((note) => ({...note, options: {...note.options, allowDuplicate: false}})));
|
||||
notes = notes.filter((_, i) => duplicateNotes[i]);
|
||||
}
|
||||
const addNotesResult = await this._ankiController.addNotes(notes);
|
||||
if (addNotesResult === null || addNotesResult.includes(null)) {
|
||||
this._updateProgressBarError('Ankiconnect error: Failed to add cards');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
this._updateProgressBarError('Ankiconnect error: ' + error.message + '');
|
||||
log.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} init
|
||||
* @param {string} text
|
||||
* @param {number} current
|
||||
* @param {number} end
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
_updateProgressBar(init, text, current, end, visible) {
|
||||
if (!visible) {
|
||||
for (const progress of this._progressContainers) { progress.hidden = true; }
|
||||
return;
|
||||
}
|
||||
if (init) {
|
||||
for (const progress of this._progressContainers) {
|
||||
progress.hidden = false;
|
||||
for (const infoLabel of progress.querySelectorAll('.progress-info')) {
|
||||
infoLabel.textContent = text;
|
||||
infoLabel.classList.remove('danger-text');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const progress of this._progressContainers) {
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const statusLabels = progress.querySelectorAll('.progress-status');
|
||||
for (const statusLabel of statusLabels) { statusLabel.textContent = ((current / end) * 100).toFixed(0).toString() + '%'; }
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const progressBars = progress.querySelectorAll('.progress-bar');
|
||||
for (const progressBar of progressBars) { progressBar.style.width = ((current / end) * 100).toString() + '%'; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
_updateProgressBarError(text) {
|
||||
for (const progress of this._progressContainers) {
|
||||
progress.hidden = false;
|
||||
for (const infoLabel of progress.querySelectorAll('.progress-info')) {
|
||||
infoLabel.textContent = text;
|
||||
infoLabel.classList.add('danger-text');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} infoNode
|
||||
* @param {boolean} showSuccessResult
|
||||
*/
|
||||
async _testNoteData(infoNode, showSuccessResult) {
|
||||
/** @type {Error[]} */
|
||||
const allErrors = [];
|
||||
const text = /** @type {HTMLInputElement} */ (this._renderTextInput).value;
|
||||
let result;
|
||||
try {
|
||||
const noteData = await this._generateNoteData(text, false);
|
||||
result = noteData ? this._fieldsToTSV(noteData.fields) : `No definition found for ${text}`;
|
||||
} catch (e) {
|
||||
allErrors.push(toError(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Error} e
|
||||
* @returns {string}
|
||||
*/
|
||||
const errorToMessageString = (e) => {
|
||||
if (e instanceof ExtensionError) {
|
||||
const v = e.data;
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
const v2 = /** @type {import('core').UnknownObject} */ (v).error;
|
||||
if (v2 instanceof Error) {
|
||||
return v2.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return e.message;
|
||||
};
|
||||
|
||||
const hasError = allErrors.length > 0;
|
||||
infoNode.hidden = !(showSuccessResult || hasError);
|
||||
if (hasError || !result) {
|
||||
infoNode.textContent = allErrors.map(errorToMessageString).join('\n');
|
||||
} else {
|
||||
infoNode.textContent = showSuccessResult ? result : '';
|
||||
}
|
||||
infoNode.classList.toggle('text-danger', hasError);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} word
|
||||
* @param {boolean} addMedia
|
||||
* @returns {Promise<?import('anki.js').Note>}
|
||||
*/
|
||||
async _generateNoteData(word, addMedia) {
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
const activeFlashcardFormatDetails = this._flashcardFormatDetails[Number(this._activeFlashcardFormatSelect.value)];
|
||||
const data = await this._getDictionaryEntry(word, optionsContext, activeFlashcardFormatDetails.type);
|
||||
|
||||
if (data === null) {
|
||||
return null;
|
||||
}
|
||||
const {dictionaryEntry, text: sentenceText} = data;
|
||||
const options = await this._settingsController.getOptions();
|
||||
const context = {
|
||||
url: window.location.href,
|
||||
sentence: {
|
||||
text: sentenceText,
|
||||
offset: 0,
|
||||
},
|
||||
documentTitle: document.title,
|
||||
query: sentenceText,
|
||||
fullQuery: sentenceText,
|
||||
};
|
||||
const template = await this._getAnkiTemplate(options);
|
||||
const deckOptionsFields = activeFlashcardFormatDetails.fields;
|
||||
const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options;
|
||||
const idleTimeout = (Number.isFinite(options.anki.downloadTimeout) && options.anki.downloadTimeout > 0 ? options.anki.downloadTimeout : null);
|
||||
const languageSummary = getLanguageSummaries().find(({iso}) => iso === options.general.language);
|
||||
const requiredAudioSources = options.audio.enableDefaultAudioSources ? getRequiredAudioSources(options.general.language, options.audio.sources) : [];
|
||||
const mediaOptions = addMedia ? {audio: {sources: [...options.audio.sources, ...requiredAudioSources], preferredAudioIndex: null, idleTimeout: idleTimeout, languageSummary: languageSummary}} : null;
|
||||
const requirements = addMedia ? [...getDictionaryEntryMedia(dictionaryEntry), {type: 'audio'}] : [];
|
||||
const dictionaryStylesMap = this._ankiNoteBuilder.getDictionaryStylesMap(options.dictionaries);
|
||||
const cardFormat = /** @type {import('settings').AnkiCardFormat} */ ({
|
||||
deck: this._activeAnkiDeck,
|
||||
model: this._activeNoteType,
|
||||
fields: deckOptionsFields,
|
||||
type: activeFlashcardFormatDetails.type,
|
||||
name: '',
|
||||
icon: 'big-circle',
|
||||
});
|
||||
const {note} = await this._ankiNoteBuilder.createNote(/** @type {import('anki-note-builder').CreateNoteDetails} */ ({
|
||||
dictionaryEntry,
|
||||
cardFormat,
|
||||
context,
|
||||
template,
|
||||
resultOutputMode,
|
||||
glossaryLayoutMode,
|
||||
compactTags,
|
||||
tags: options.anki.tags,
|
||||
mediaOptions: mediaOptions,
|
||||
requirements: requirements,
|
||||
duplicateScope: options.anki.duplicateScope,
|
||||
duplicateScopeCheckAllModels: options.anki.duplicateScopeCheckAllModels,
|
||||
dictionaryStylesMap: dictionaryStylesMap,
|
||||
}));
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import('settings').OptionsContext} optionsContext
|
||||
* @param {import('settings').AnkiCardFormatType} type
|
||||
* @returns {Promise<?{dictionaryEntry: (import('dictionary').DictionaryEntry), text: string}>}
|
||||
*/
|
||||
async _getDictionaryEntry(text, optionsContext, type) {
|
||||
let dictionaryEntriesTermKanji = null;
|
||||
if (type === 'term') {
|
||||
const {dictionaryEntries} = await this._settingsController.application.api.termsFind(text, {}, optionsContext);
|
||||
dictionaryEntriesTermKanji = dictionaryEntries;
|
||||
}
|
||||
if (type === 'kanji') {
|
||||
dictionaryEntriesTermKanji = await this._settingsController.application.api.kanjiFind(text[0], optionsContext);
|
||||
}
|
||||
|
||||
if (!dictionaryEntriesTermKanji || dictionaryEntriesTermKanji.length === 0) { return null; }
|
||||
|
||||
return {
|
||||
dictionaryEntry: /** @type {import('dictionary').DictionaryEntry} */ (dictionaryEntriesTermKanji[0]),
|
||||
text: text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _getAnkiTemplate(options) {
|
||||
let staticTemplates = options.anki.fieldTemplates;
|
||||
if (typeof staticTemplates !== 'string') { staticTemplates = this._defaultFieldTemplates; }
|
||||
const dictionaryInfo = await this._application.api.getDictionaryInfo();
|
||||
const dynamicTemplates = getDynamicTemplates(options, dictionaryInfo);
|
||||
return staticTemplates + '\n' + dynamicTemplates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onRender(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const infoNode = /** @type {HTMLElement} */ (this._renderResult);
|
||||
infoNode.hidden = true;
|
||||
void this._testNoteData(infoNode, true);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateExampleText() {
|
||||
const languageSummaries = await this._application.api.getLanguageSummaries();
|
||||
const options = await this._settingsController.getOptions();
|
||||
const activeLanguage = /** @type {import('language').LanguageSummary} */ (languageSummaries.find(({iso}) => iso === options.general.language));
|
||||
this._renderTextInput.lang = options.general.language;
|
||||
this._renderTextInput.value = activeLanguage.exampleText;
|
||||
this._renderResult.lang = options.general.language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki.js').NoteFields} noteFields
|
||||
* @returns {string}
|
||||
*/
|
||||
_fieldsToTSV(noteFields) {
|
||||
let tsv = '';
|
||||
for (const key in noteFields) {
|
||||
if (Object.prototype.hasOwnProperty.call(noteFields, key)) {
|
||||
tsv += noteFields[key].replaceAll('\t', ' ').replaceAll('\n', '').replaceAll('\r', '') + '\t';
|
||||
}
|
||||
}
|
||||
return tsv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob} blob
|
||||
* @param {string} fileName
|
||||
*/
|
||||
_saveBlob(blob, fileName) {
|
||||
if (
|
||||
typeof navigator === 'object' && navigator !== null &&
|
||||
// @ts-expect-error - call for legacy Edge
|
||||
typeof navigator.msSaveBlob === 'function' &&
|
||||
// @ts-expect-error - call for legacy Edge
|
||||
navigator.msSaveBlob(blob)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = fileName;
|
||||
a.rel = 'noopener';
|
||||
a.target = '_blank';
|
||||
|
||||
const revoke = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
a.href = '';
|
||||
this._settingsExportRevoke = null;
|
||||
};
|
||||
this._settingsExportRevoke = revoke;
|
||||
|
||||
a.dispatchEvent(new MouseEvent('click'));
|
||||
setTimeout(revoke, 60000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
|
||||
* @returns {Array<import('anki-note-builder').RequirementDictionaryMedia>}
|
||||
*/
|
||||
export function getDictionaryEntryMedia(dictionaryEntry) {
|
||||
if (dictionaryEntry.type !== 'term') {
|
||||
return [];
|
||||
}
|
||||
/** @type {Array<import('anki-note-builder').RequirementDictionaryMedia>} */
|
||||
const media = [];
|
||||
const definitions = dictionaryEntry.definitions;
|
||||
for (const definition of definitions) {
|
||||
const paths = [...new Set(findAllPaths(definition))];
|
||||
for (const path of paths) {
|
||||
media.push({dictionary: definition.dictionary, path: path, type: 'dictionaryMedia'});
|
||||
}
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all values of json keys named `path` which contain a string value.
|
||||
* Example json snippet containing a path:
|
||||
* ...","path":"example-dictionary/svg/example-media.svg","...
|
||||
* The path can be found in many different positions in the structure of the definition json.
|
||||
* It is most reliable to flatten it to a string and use regex.
|
||||
* @param {object} obj
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
function findAllPaths(obj) {
|
||||
return JSON.stringify(obj).match(/(?<="path":").*?(?=")/g) ?? [];
|
||||
}
|
||||
335
vendor/yomitan/js/pages/settings/anki-templates-controller.js
vendored
Normal file
335
vendor/yomitan/js/pages/settings/anki-templates-controller.js
vendored
Normal file
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ExtensionError} from '../../core/extension-error.js';
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {AnkiNoteBuilder} from '../../data/anki-note-builder.js';
|
||||
import {getDynamicTemplates} from '../../data/anki-template-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {TemplateRendererProxy} from '../../templates/template-renderer-proxy.js';
|
||||
|
||||
export class AnkiTemplatesController {
|
||||
/**
|
||||
* @param {import('../../application.js').Application} application
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {import('./modal-controller.js').ModalController} modalController
|
||||
* @param {import('./anki-controller.js').AnkiController} ankiController
|
||||
*/
|
||||
constructor(application, settingsController, modalController, ankiController) {
|
||||
/** @type {import('../../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {import('./modal-controller.js').ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {import('./anki-controller.js').AnkiController} */
|
||||
this._ankiController = ankiController;
|
||||
/** @type {?import('dictionary').TermDictionaryEntry} */
|
||||
this._cachedDictionaryEntryValue = null;
|
||||
/** @type {?string} */
|
||||
this._cachedDictionaryEntryText = null;
|
||||
/** @type {?string} */
|
||||
this._defaultFieldTemplates = null;
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
this._fieldTemplatesTextarea = querySelectorNotNull(document, '#anki-card-templates-textarea');
|
||||
/** @type {HTMLElement} */
|
||||
this._compileResultInfo = querySelectorNotNull(document, '#anki-card-templates-compile-result');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._renderFieldInput = querySelectorNotNull(document, '#anki-card-templates-test-field-input');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._renderTextInput = querySelectorNotNull(document, '#anki-card-templates-test-text-input');
|
||||
/** @type {HTMLElement} */
|
||||
this._renderResult = querySelectorNotNull(document, '#anki-card-templates-render-result');
|
||||
/** @type {HTMLElement} */
|
||||
this._mainSettingsEntry = querySelectorNotNull(document, '[data-modal-action="show,anki-card-templates"]');
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._fieldTemplateResetModal = null;
|
||||
/** @type {AnkiNoteBuilder} */
|
||||
this._ankiNoteBuilder = new AnkiNoteBuilder(settingsController.application.api, new TemplateRendererProxy());
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._defaultFieldTemplates = await this._settingsController.application.api.getDefaultAnkiFieldTemplates();
|
||||
|
||||
/** @type {HTMLButtonElement} */
|
||||
const menuButton = querySelectorNotNull(document, '#anki-card-templates-test-field-menu-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const testRenderButton = querySelectorNotNull(document, '#anki-card-templates-test-render-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const resetButton = querySelectorNotNull(document, '#anki-card-templates-reset-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const resetConfirmButton = querySelectorNotNull(document, '#anki-card-templates-reset-button-confirm');
|
||||
this._fieldTemplateResetModal = this._modalController.getModal('anki-card-templates-reset');
|
||||
|
||||
this._fieldTemplatesTextarea.addEventListener('change', this._onChanged.bind(this), false);
|
||||
testRenderButton.addEventListener('click', this._onRender.bind(this), false);
|
||||
resetButton.addEventListener('click', this._onReset.bind(this), false);
|
||||
resetConfirmButton.addEventListener('click', this._onResetConfirm.bind(this), false);
|
||||
if (menuButton !== null) {
|
||||
menuButton.addEventListener(
|
||||
/** @type {string} */ ('menuClose'),
|
||||
/** @type {EventListener} */ (this._onFieldMenuClose.bind(this)),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
|
||||
void this._updateExampleText();
|
||||
this._mainSettingsEntry.addEventListener('click', this._updateExampleText.bind(this), false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
let templates = options.anki.fieldTemplates;
|
||||
if (typeof templates !== 'string') {
|
||||
templates = this._defaultFieldTemplates;
|
||||
if (typeof templates !== 'string') { templates = ''; }
|
||||
}
|
||||
/** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea).value = templates;
|
||||
|
||||
this._onValidateCompile();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onReset(e) {
|
||||
e.preventDefault();
|
||||
if (this._fieldTemplateResetModal !== null) {
|
||||
this._fieldTemplateResetModal.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onResetConfirm(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this._fieldTemplateResetModal !== null) {
|
||||
this._fieldTemplateResetModal.setVisible(false);
|
||||
}
|
||||
|
||||
const value = this._defaultFieldTemplates;
|
||||
|
||||
const textarea = /** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea);
|
||||
textarea.value = typeof value === 'string' ? value : '';
|
||||
textarea.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
async _onChanged(e) {
|
||||
// Get value
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
/** @type {?string} */
|
||||
let templates = element.value;
|
||||
if (templates === this._defaultFieldTemplates) {
|
||||
// Default
|
||||
templates = null;
|
||||
}
|
||||
|
||||
// Overwrite
|
||||
await this._settingsController.setProfileSetting('anki.fieldTemplates', templates);
|
||||
|
||||
// Compile
|
||||
this._onValidateCompile();
|
||||
}
|
||||
|
||||
/** */
|
||||
_onValidateCompile() {
|
||||
if (this._compileResultInfo === null) { return; }
|
||||
void this._validate(this._compileResultInfo, '{expression}', false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onRender(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const field = /** @type {HTMLInputElement} */ (this._renderFieldInput).value;
|
||||
const infoNode = /** @type {HTMLElement} */ (this._renderResult);
|
||||
infoNode.hidden = true;
|
||||
this._cachedDictionaryEntryText = null;
|
||||
void this._validate(infoNode, field, true, false);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateExampleText() {
|
||||
const languageSummaries = await this._application.api.getLanguageSummaries();
|
||||
const options = await this._settingsController.getOptions();
|
||||
const activeLanguage = /** @type {import('language').LanguageSummary} */ (languageSummaries.find(({iso}) => iso === options.general.language));
|
||||
this._renderTextInput.lang = options.general.language;
|
||||
this._renderTextInput.value = activeLanguage.exampleText;
|
||||
this._renderResult.lang = options.general.language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} event
|
||||
*/
|
||||
_onFieldMenuClose({detail: {action, item}}) {
|
||||
switch (action) {
|
||||
case 'setFieldMarker':
|
||||
{
|
||||
const {marker} = /** @type {HTMLElement} */ (item).dataset;
|
||||
if (typeof marker === 'string') {
|
||||
this._setFieldMarker(marker);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} marker
|
||||
*/
|
||||
_setFieldMarker(marker) {
|
||||
const input = /** @type {HTMLInputElement} */ (this._renderFieldInput);
|
||||
input.value = `{${marker}}`;
|
||||
input.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import('settings').OptionsContext} optionsContext
|
||||
* @returns {Promise<?{dictionaryEntry: import('dictionary').TermDictionaryEntry, text: string}>}
|
||||
*/
|
||||
async _getDictionaryEntry(text, optionsContext) {
|
||||
if (this._cachedDictionaryEntryText !== text) {
|
||||
const {dictionaryEntries} = await this._settingsController.application.api.termsFind(text, {}, optionsContext);
|
||||
if (dictionaryEntries.length === 0) { return null; }
|
||||
|
||||
this._cachedDictionaryEntryValue = dictionaryEntries[0];
|
||||
this._cachedDictionaryEntryText = text;
|
||||
}
|
||||
return {
|
||||
dictionaryEntry: /** @type {import('dictionary').TermDictionaryEntry} */ (this._cachedDictionaryEntryValue),
|
||||
text: this._cachedDictionaryEntryText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} infoNode
|
||||
* @param {string} field
|
||||
* @param {boolean} showSuccessResult
|
||||
* @param {boolean} invalidateInput
|
||||
*/
|
||||
async _validate(infoNode, field, showSuccessResult, invalidateInput) {
|
||||
/** @type {Error[]} */
|
||||
const allErrors = [];
|
||||
const text = /** @type {HTMLInputElement} */ (this._renderTextInput).value;
|
||||
let result = `No definition found for ${text}`;
|
||||
try {
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
const data = await this._getDictionaryEntry(text, optionsContext);
|
||||
if (data !== null) {
|
||||
const {dictionaryEntry, text: sentenceText} = data;
|
||||
const options = await this._settingsController.getOptions();
|
||||
const context = {
|
||||
url: window.location.href,
|
||||
sentence: {
|
||||
text: sentenceText,
|
||||
offset: 0,
|
||||
},
|
||||
documentTitle: document.title,
|
||||
query: sentenceText,
|
||||
fullQuery: sentenceText,
|
||||
};
|
||||
const template = await this._getAnkiTemplate(options);
|
||||
const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options;
|
||||
const fields = {
|
||||
field: {
|
||||
value: field,
|
||||
overwriteMode: 'skip',
|
||||
},
|
||||
};
|
||||
const cardFormat = /** @type {import('settings').AnkiCardFormat} */ ({
|
||||
type: 'term',
|
||||
name: '',
|
||||
deck: '',
|
||||
model: '',
|
||||
fields,
|
||||
icon: 'big-circle',
|
||||
});
|
||||
const {note, errors} = await this._ankiNoteBuilder.createNote(/** @type {import('anki-note-builder').CreateNoteDetails} */ ({
|
||||
dictionaryEntry,
|
||||
context,
|
||||
template,
|
||||
cardFormat,
|
||||
resultOutputMode,
|
||||
glossaryLayoutMode,
|
||||
compactTags,
|
||||
dictionaryStylesMap: this._ankiNoteBuilder.getDictionaryStylesMap(options.dictionaries),
|
||||
}));
|
||||
result = note.fields.field;
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
} catch (e) {
|
||||
allErrors.push(toError(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Error} e
|
||||
* @returns {string}
|
||||
*/
|
||||
const errorToMessageString = (e) => {
|
||||
if (e instanceof ExtensionError) {
|
||||
const v = e.data;
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
const v2 = /** @type {import('core').UnknownObject} */ (v).error;
|
||||
if (v2 instanceof Error) {
|
||||
return v2.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return e.message;
|
||||
};
|
||||
|
||||
const hasError = allErrors.length > 0;
|
||||
infoNode.hidden = !(showSuccessResult || hasError);
|
||||
infoNode.textContent = hasError ? allErrors.map(errorToMessageString).join('\n') : (showSuccessResult ? result : '');
|
||||
infoNode.classList.toggle('text-danger', hasError);
|
||||
if (invalidateInput) {
|
||||
/** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea).dataset.invalid = `${hasError}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _getAnkiTemplate(options) {
|
||||
let staticTemplates = options.anki.fieldTemplates;
|
||||
if (typeof staticTemplates !== 'string') { staticTemplates = this._defaultFieldTemplates; }
|
||||
const dictionaryInfo = await this._application.api.getDictionaryInfo();
|
||||
const dynamicTemplates = getDynamicTemplates(options, dictionaryInfo);
|
||||
return staticTemplates + '\n' + dynamicTemplates;
|
||||
}
|
||||
}
|
||||
604
vendor/yomitan/js/pages/settings/audio-controller.js
vendored
Normal file
604
vendor/yomitan/js/pages/settings/audio-controller.js
vendored
Normal file
@@ -0,0 +1,604 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventDispatcher} from '../../core/event-dispatcher.js';
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {AudioSystem} from '../../media/audio-system.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('audio-controller').Events>
|
||||
*/
|
||||
export class AudioController extends EventDispatcher {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {import('./modal-controller.js').ModalController} modalController
|
||||
*/
|
||||
constructor(settingsController, modalController) {
|
||||
super();
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {import('./modal-controller.js').ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {AudioSystem} */
|
||||
this._audioSystem = new AudioSystem();
|
||||
/** @type {HTMLElement} */
|
||||
this._audioSourceContainer = querySelectorNotNull(document, '#audio-source-list');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._audioSourceAddButton = querySelectorNotNull(document, '#audio-source-add');
|
||||
/** @type {AudioSourceEntry[]} */
|
||||
this._audioSourceEntries = [];
|
||||
/** @type {HTMLInputElement} */
|
||||
this._voiceTestTextInput = querySelectorNotNull(document, '#text-to-speech-voice-test-text');
|
||||
/** @type {import('audio-controller').VoiceInfo[]} */
|
||||
this._voices = [];
|
||||
/** @type {string} */
|
||||
this._language = 'ja';
|
||||
}
|
||||
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
get settingsController() {
|
||||
return this._settingsController;
|
||||
}
|
||||
|
||||
/** @type {import('./modal-controller.js').ModalController} */
|
||||
get modalController() {
|
||||
return this._modalController;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get audioSourceCount() {
|
||||
return this._audioSourceEntries.length;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._audioSystem.prepare();
|
||||
|
||||
this._audioSourceContainer.textContent = '';
|
||||
/** @type {HTMLButtonElement} */
|
||||
const testButton = querySelectorNotNull(document, '#text-to-speech-voice-test');
|
||||
|
||||
/** @type {HTMLButtonElement} */
|
||||
const audioSourceMoveButton = querySelectorNotNull(document, '#audio-source-move-button');
|
||||
|
||||
audioSourceMoveButton.addEventListener('click', this._onAudioSourceMoveButtonClick.bind(this), false);
|
||||
|
||||
this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false);
|
||||
|
||||
this._audioSystem.on('voiceschanged', this._updateTextToSpeechVoices.bind(this));
|
||||
this._updateTextToSpeechVoices();
|
||||
|
||||
testButton.addEventListener('click', this._onTestTextToSpeech.bind(this), false);
|
||||
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AudioSourceEntry} entry
|
||||
*/
|
||||
async removeSource(entry) {
|
||||
const {index} = entry;
|
||||
this._audioSourceEntries.splice(index, 1);
|
||||
entry.cleanup();
|
||||
for (let i = index, ii = this._audioSourceEntries.length; i < ii; ++i) {
|
||||
this._audioSourceEntries[i].index = i;
|
||||
}
|
||||
|
||||
await this._settingsController.modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'audio.sources',
|
||||
start: index,
|
||||
deleteCount: 1,
|
||||
items: [],
|
||||
}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} currentIndex
|
||||
* @param {number} targetIndex
|
||||
*/
|
||||
async moveAudioSourceOptions(currentIndex, targetIndex) {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
const {audio} = options;
|
||||
if (
|
||||
currentIndex < 0 || currentIndex >= audio.sources.length ||
|
||||
targetIndex < 0 || targetIndex >= audio.sources.length ||
|
||||
currentIndex === targetIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = audio.sources.splice(currentIndex, 1)[0];
|
||||
audio.sources.splice(targetIndex, 0, item);
|
||||
|
||||
await this._settingsController.modifyProfileSettings([{
|
||||
action: 'set',
|
||||
path: 'audio.sources',
|
||||
value: audio.sources,
|
||||
}]);
|
||||
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('audio-controller').VoiceInfo[]}
|
||||
*/
|
||||
getVoices() {
|
||||
return this._voices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} voice
|
||||
*/
|
||||
setTestVoice(voice) {
|
||||
/** @type {HTMLInputElement} */ (this._voiceTestTextInput).dataset.voice = voice;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
const {
|
||||
general: {language},
|
||||
audio: {sources},
|
||||
} = options;
|
||||
|
||||
this._language = language;
|
||||
|
||||
for (const entry of this._audioSourceEntries) {
|
||||
entry.cleanup();
|
||||
}
|
||||
this._audioSourceEntries = [];
|
||||
|
||||
for (let i = 0, ii = sources.length; i < ii; ++i) {
|
||||
this._createAudioSourceEntry(i, sources[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_onAddAudioSource() {
|
||||
void this._addAudioSource();
|
||||
}
|
||||
|
||||
/** */
|
||||
_onTestTextToSpeech() {
|
||||
try {
|
||||
const input = /** @type {HTMLInputElement} */ (this._voiceTestTextInput);
|
||||
const text = input.value || '';
|
||||
const voiceUri = input.dataset.voice;
|
||||
const audio = this._audioSystem.createTextToSpeechAudio(text, typeof voiceUri === 'string' ? voiceUri : '');
|
||||
audio.volume = 1;
|
||||
void audio.play();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateTextToSpeechVoices() {
|
||||
const voices = (
|
||||
typeof speechSynthesis !== 'undefined' ?
|
||||
[...speechSynthesis.getVoices()].map((voice, index) => ({
|
||||
voice,
|
||||
isJapanese: this._languageTagIsJapanese(voice.lang),
|
||||
index,
|
||||
})) :
|
||||
[]
|
||||
);
|
||||
voices.sort(this._textToSpeechVoiceCompare.bind(this));
|
||||
this._voices = voices;
|
||||
this.trigger('voicesUpdated', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('audio-controller').VoiceInfo} a
|
||||
* @param {import('audio-controller').VoiceInfo} b
|
||||
* @returns {number}
|
||||
*/
|
||||
_textToSpeechVoiceCompare(a, b) {
|
||||
if (a.isJapanese) {
|
||||
if (!b.isJapanese) { return -1; }
|
||||
} else {
|
||||
if (b.isJapanese) { return 1; }
|
||||
}
|
||||
|
||||
if (a.voice.default) {
|
||||
if (!b.voice.default) { return -1; }
|
||||
} else {
|
||||
if (b.voice.default) { return 1; }
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} languageTag
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_languageTagIsJapanese(languageTag) {
|
||||
return (
|
||||
languageTag.startsWith('ja_') ||
|
||||
languageTag.startsWith('ja-') ||
|
||||
languageTag.startsWith('jpn-')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @param {import('settings').AudioSourceOptions} source
|
||||
*/
|
||||
_createAudioSourceEntry(index, source) {
|
||||
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('audio-source'));
|
||||
const entry = new AudioSourceEntry(this, index, source, node);
|
||||
this._audioSourceEntries.push(entry);
|
||||
/** @type {HTMLElement} */ (this._audioSourceContainer).appendChild(node);
|
||||
entry.prepare();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('settings').AudioSourceType}
|
||||
*/
|
||||
_getUnusedAudioSourceType() {
|
||||
const typesAvailable = this._getAvailableAudioSourceTypes();
|
||||
for (const type of typesAvailable) {
|
||||
if (!this._audioSourceEntries.some((entry) => entry.type === type)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return typesAvailable[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('settings').AudioSourceType[]}
|
||||
*/
|
||||
_getAvailableAudioSourceTypes() {
|
||||
/** @type {import('settings').AudioSourceType[]} */
|
||||
const generalAudioSources = ['language-pod-101', 'lingua-libre', 'wiktionary', 'text-to-speech', 'custom'];
|
||||
if (this._language === 'ja') {
|
||||
/** @type {import('settings').AudioSourceType[]} */
|
||||
const japaneseAudioSources = ['jpod101', 'jisho'];
|
||||
return [...japaneseAudioSources, ...generalAudioSources];
|
||||
}
|
||||
return generalAudioSources;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _addAudioSource() {
|
||||
const type = this._getUnusedAudioSourceType();
|
||||
/** @type {import('settings').AudioSourceOptions} */
|
||||
const source = {type, url: '', voice: ''};
|
||||
const index = this._audioSourceEntries.length;
|
||||
this._createAudioSourceEntry(index, source);
|
||||
await this._settingsController.modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'audio.sources',
|
||||
start: index,
|
||||
deleteCount: 0,
|
||||
items: [source],
|
||||
}]);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onAudioSourceMoveButtonClick() {
|
||||
const modal = /** @type {import('./modal.js').Modal} */ (this._modalController.getModal('audio-source-move-location'));
|
||||
const index = modal.node.dataset.index ?? '';
|
||||
const indexNumber = Number.parseInt(index, 10);
|
||||
if (Number.isNaN(indexNumber)) { return; }
|
||||
|
||||
/** @type {HTMLInputElement} */
|
||||
const targetStringInput = querySelectorNotNull(document, '#audio-source-move-location');
|
||||
const targetString = targetStringInput.value;
|
||||
const target = Number.parseInt(targetString, 10) - 1;
|
||||
|
||||
if (!Number.isFinite(target) || !Number.isFinite(indexNumber) || indexNumber === target) { return; }
|
||||
|
||||
void this.moveAudioSourceOptions(indexNumber, target);
|
||||
}
|
||||
}
|
||||
|
||||
class AudioSourceEntry {
|
||||
/**
|
||||
* @param {AudioController} parent
|
||||
* @param {number} index
|
||||
* @param {import('settings').AudioSourceOptions} source
|
||||
* @param {HTMLElement} node
|
||||
*/
|
||||
constructor(parent, index, source, node) {
|
||||
/** @type {AudioController} */
|
||||
this._parent = parent;
|
||||
/** @type {number} */
|
||||
this._index = index;
|
||||
/** @type {import('settings').AudioSourceType} */
|
||||
this._type = source.type;
|
||||
/** @type {string} */
|
||||
this._url = source.url;
|
||||
/** @type {string} */
|
||||
this._voice = source.voice;
|
||||
/** @type {HTMLElement} */
|
||||
this._node = node;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._typeSelect = querySelectorNotNull(this._node, '.audio-source-type-select');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._urlInput = querySelectorNotNull(this._node, '.audio-source-parameter-container[data-field=url] .audio-source-parameter');
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._voiceSelect = querySelectorNotNull(this._node, '.audio-source-parameter-container[data-field=voice] .audio-source-parameter');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._upButton = querySelectorNotNull(this._node, '#audio-source-move-up');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._downButton = querySelectorNotNull(this._node, '#audio-source-move-down');
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get index() {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
set index(value) {
|
||||
this._index = value;
|
||||
}
|
||||
|
||||
/** @type {import('settings').AudioSourceType} */
|
||||
get type() {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._updateTypeParameter();
|
||||
|
||||
/** @type {HTMLButtonElement} */
|
||||
const menuButton = querySelectorNotNull(this._node, '.audio-source-menu-button');
|
||||
|
||||
this._typeSelect.value = this._type;
|
||||
this._urlInput.value = this._url;
|
||||
|
||||
this._eventListeners.addEventListener(this._typeSelect, 'change', this._onTypeSelectChange.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._urlInput, 'change', this._onUrlInputChange.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._voiceSelect, 'change', this._onVoiceSelectChange.bind(this), false);
|
||||
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
|
||||
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._upButton, 'click', (() => { this._move(-1); }).bind(this), false);
|
||||
this._eventListeners.addEventListener(this._downButton, 'click', (() => { this._move(1); }).bind(this), false);
|
||||
this._eventListeners.on(this._parent, 'voicesUpdated', this._onVoicesUpdated.bind(this));
|
||||
this._onVoicesUpdated();
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
if (this._node.parentNode !== null) {
|
||||
this._node.parentNode.removeChild(this._node);
|
||||
}
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onVoicesUpdated() {
|
||||
if (this._voiceSelect === null) { return; }
|
||||
const voices = this._parent.getVoices();
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
let option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'None';
|
||||
fragment.appendChild(option);
|
||||
|
||||
for (const {voice} of voices) {
|
||||
option = document.createElement('option');
|
||||
option.value = voice.voiceURI;
|
||||
option.textContent = `${voice.name} (${voice.lang})`;
|
||||
fragment.appendChild(option);
|
||||
}
|
||||
|
||||
this._voiceSelect.textContent = '';
|
||||
this._voiceSelect.appendChild(fragment);
|
||||
this._voiceSelect.value = this._voice;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
*/
|
||||
_move(offset) {
|
||||
void this._parent.moveAudioSourceOptions(this._index, this._index + offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onTypeSelectChange(e) {
|
||||
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
|
||||
const value = this._normalizeAudioSourceType(element.value);
|
||||
if (value === null) { return; }
|
||||
void this._setType(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onUrlInputChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
void this._setUrl(element.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onVoiceSelectChange(e) {
|
||||
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
|
||||
void this._setVoice(element.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuOpenEvent} e
|
||||
*/
|
||||
_onMenuOpen(e) {
|
||||
const {menu} = e.detail;
|
||||
|
||||
let hasHelp = false;
|
||||
switch (this._type) {
|
||||
case 'custom':
|
||||
case 'custom-json':
|
||||
case 'text-to-speech':
|
||||
case 'text-to-speech-reading':
|
||||
hasHelp = true;
|
||||
break;
|
||||
}
|
||||
|
||||
/** @type {?HTMLButtonElement} */
|
||||
const helpNode = menu.bodyNode.querySelector('.popup-menu-item[data-menu-action=help]');
|
||||
if (helpNode !== null) {
|
||||
helpNode.disabled = !hasHelp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onMenuClose(e) {
|
||||
switch (e.detail.action) {
|
||||
case 'help':
|
||||
this._showHelp(this._type);
|
||||
break;
|
||||
case 'moveTo':
|
||||
this._showMoveToModal();
|
||||
break;
|
||||
case 'remove':
|
||||
void this._parent.removeSource(this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').AudioSourceType} value
|
||||
*/
|
||||
async _setType(value) {
|
||||
this._type = value;
|
||||
this._updateTypeParameter();
|
||||
await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].type`, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
async _setUrl(value) {
|
||||
this._url = value;
|
||||
await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].url`, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
async _setVoice(value) {
|
||||
this._voice = value;
|
||||
await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].voice`, value);
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateTypeParameter() {
|
||||
let field = null;
|
||||
switch (this._type) {
|
||||
case 'custom':
|
||||
case 'custom-json':
|
||||
field = 'url';
|
||||
break;
|
||||
case 'text-to-speech':
|
||||
case 'text-to-speech-reading':
|
||||
field = 'voice';
|
||||
break;
|
||||
}
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.audio-source-parameter-container'))) {
|
||||
node.hidden = (field === null || node.dataset.field !== field);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').AudioSourceType} type
|
||||
*/
|
||||
_showHelp(type) {
|
||||
switch (type) {
|
||||
case 'custom':
|
||||
this._showModal('audio-source-help-custom');
|
||||
break;
|
||||
case 'custom-json':
|
||||
this._showModal('audio-source-help-custom-json');
|
||||
break;
|
||||
case 'text-to-speech':
|
||||
case 'text-to-speech-reading':
|
||||
this._parent.setTestVoice(this._voice);
|
||||
this._showModal('audio-source-help-text-to-speech');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_showMoveToModal() {
|
||||
const modal = this._parent.modalController.getModal('audio-source-move-location');
|
||||
if (modal === null) { return; }
|
||||
const count = this._parent.audioSourceCount;
|
||||
/** @type {HTMLInputElement} */
|
||||
const input = querySelectorNotNull(modal.node, '#audio-source-move-location');
|
||||
|
||||
modal.node.dataset.index = `${this._index}`;
|
||||
input.value = `${this._index + 1}`;
|
||||
input.max = `${count}`;
|
||||
|
||||
modal.setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
_showModal(name) {
|
||||
const modal = this._parent.modalController.getModal(name);
|
||||
if (modal === null) { return; }
|
||||
modal.setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {?import('settings').AudioSourceType}
|
||||
*/
|
||||
_normalizeAudioSourceType(value) {
|
||||
switch (value) {
|
||||
case 'jpod101':
|
||||
case 'language-pod-101':
|
||||
case 'jisho':
|
||||
case 'lingua-libre':
|
||||
case 'wiktionary':
|
||||
case 'text-to-speech':
|
||||
case 'text-to-speech-reading':
|
||||
case 'custom':
|
||||
case 'custom-json':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
717
vendor/yomitan/js/pages/settings/backup-controller.js
vendored
Normal file
717
vendor/yomitan/js/pages/settings/backup-controller.js
vendored
Normal file
@@ -0,0 +1,717 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Dexie} from '../../../lib/dexie.js';
|
||||
import {ThemeController} from '../../app/theme-controller.js';
|
||||
import {parseJson} from '../../core/json.js';
|
||||
import {log} from '../../core/log.js';
|
||||
import {isObjectNotArray} from '../../core/object-utilities.js';
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {arrayBufferUtf8Decode} from '../../data/array-buffer-util.js';
|
||||
import {OptionsUtil} from '../../data/options-util.js';
|
||||
import {getAllPermissions} from '../../data/permissions-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {DictionaryController} from './dictionary-controller.js';
|
||||
|
||||
export class BackupController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {?import('./modal-controller.js').ModalController} modalController
|
||||
*/
|
||||
constructor(settingsController, modalController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {?import('./modal-controller.js').ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._settingsExportToken = null;
|
||||
/** @type {?() => void} */
|
||||
this._settingsExportRevoke = null;
|
||||
/** @type {number} */
|
||||
this._currentVersion = 0;
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._settingsResetModal = null;
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._settingsImportErrorModal = null;
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._settingsImportWarningModal = null;
|
||||
/** @type {?OptionsUtil} */
|
||||
this._optionsUtil = null;
|
||||
|
||||
/** @type {string} */
|
||||
this._dictionariesDatabaseName = 'dict';
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._settingsExportDatabaseToken = null;
|
||||
|
||||
try {
|
||||
this._optionsUtil = new OptionsUtil();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
/** @type {ThemeController} */
|
||||
this._themeController = new ThemeController(document.documentElement);
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
if (this._optionsUtil !== null) {
|
||||
await this._optionsUtil.prepare();
|
||||
}
|
||||
|
||||
if (this._modalController !== null) {
|
||||
this._settingsResetModal = this._modalController.getModal('settings-reset');
|
||||
this._settingsImportErrorModal = this._modalController.getModal('settings-import-error');
|
||||
this._settingsImportWarningModal = this._modalController.getModal('settings-import-warning');
|
||||
}
|
||||
|
||||
this._addNodeEventListener('#settings-export-button', 'click', this._onSettingsExportClick.bind(this), false);
|
||||
this._addNodeEventListener('#settings-import-button', 'click', this._onSettingsImportClick.bind(this), false);
|
||||
this._addNodeEventListener('#settings-import-file', 'change', this._onSettingsImportFileChange.bind(this), false);
|
||||
this._addNodeEventListener('#settings-reset-button', 'click', this._onSettingsResetClick.bind(this), false);
|
||||
this._addNodeEventListener('#settings-reset-confirm-button', 'click', this._onSettingsResetConfirmClick.bind(this), false);
|
||||
|
||||
this._addNodeEventListener('#settings-export-db-button', 'click', this._onSettingsExportDatabaseClick.bind(this), false);
|
||||
this._addNodeEventListener('#settings-import-db-button', 'click', this._onSettingsImportDatabaseClick.bind(this), false);
|
||||
this._addNodeEventListener('#settings-import-db', 'change', this._onSettingsImportDatabaseChange.bind(this), false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @param {string} eventName
|
||||
* @param {(event: Event) => void} callback
|
||||
* @param {boolean} capture
|
||||
*/
|
||||
_addNodeEventListener(selector, eventName, callback, capture) {
|
||||
const node = document.querySelector(selector);
|
||||
if (node === null) { return; }
|
||||
|
||||
node.addEventListener(eventName, callback, capture);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @param {string} dateSeparator
|
||||
* @param {string} dateTimeSeparator
|
||||
* @param {string} timeSeparator
|
||||
* @param {number} resolution
|
||||
* @returns {string}
|
||||
*/
|
||||
_getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) {
|
||||
const values = [
|
||||
date.getUTCFullYear().toString(),
|
||||
dateSeparator,
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
dateSeparator,
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
dateTimeSeparator,
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
timeSeparator,
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
timeSeparator,
|
||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||
];
|
||||
return values.slice(0, resolution * 2 - 1).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @returns {Promise<import('backup-controller').BackupData>}
|
||||
*/
|
||||
async _getSettingsExportData(date) {
|
||||
const optionsFull = await this._settingsController.getOptionsFull();
|
||||
const environment = await this._settingsController.application.api.getEnvironmentInfo();
|
||||
const fieldTemplatesDefault = await this._settingsController.application.api.getDefaultAnkiFieldTemplates();
|
||||
const permissions = await getAllPermissions();
|
||||
|
||||
// Format options
|
||||
for (const {options} of optionsFull.profiles) {
|
||||
if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) {
|
||||
options.anki.fieldTemplates = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: this._currentVersion,
|
||||
date: this._getSettingsExportDateString(date, '-', ' ', ':', 6),
|
||||
url: chrome.runtime.getURL('/'),
|
||||
manifest: chrome.runtime.getManifest(),
|
||||
environment,
|
||||
userAgent: navigator.userAgent,
|
||||
permissions,
|
||||
options: optionsFull,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob} blob
|
||||
* @param {string} fileName
|
||||
*/
|
||||
_saveBlob(blob, fileName) {
|
||||
if (
|
||||
typeof navigator === 'object' && navigator !== null &&
|
||||
// @ts-expect-error - call for legacy Edge
|
||||
typeof navigator.msSaveBlob === 'function' &&
|
||||
// @ts-expect-error - call for legacy Edge
|
||||
navigator.msSaveBlob(blob)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = fileName;
|
||||
a.rel = 'noopener';
|
||||
a.target = '_blank';
|
||||
|
||||
const revoke = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
a.href = '';
|
||||
this._settingsExportRevoke = null;
|
||||
};
|
||||
this._settingsExportRevoke = revoke;
|
||||
|
||||
a.dispatchEvent(new MouseEvent('click'));
|
||||
setTimeout(revoke, 60000);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onSettingsExportClick() {
|
||||
if (this._settingsExportRevoke !== null) {
|
||||
this._settingsExportRevoke();
|
||||
this._settingsExportRevoke = null;
|
||||
}
|
||||
|
||||
const date = new Date(Date.now());
|
||||
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._settingsExportToken = token;
|
||||
const data = await this._getSettingsExportData(date);
|
||||
if (this._settingsExportToken !== token) {
|
||||
// A new export has been started
|
||||
return;
|
||||
}
|
||||
this._settingsExportToken = null;
|
||||
|
||||
const fileName = `yomitan-settings-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`;
|
||||
const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'});
|
||||
this._saveBlob(blob, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns {Promise<ArrayBuffer>}
|
||||
*/
|
||||
_readFileArrayBuffer(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(/** @type {ArrayBuffer} */ (reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Importing
|
||||
|
||||
/**
|
||||
* @param {import('settings').Options} optionsFull
|
||||
*/
|
||||
async _settingsImportSetOptionsFull(optionsFull) {
|
||||
await this._settingsController.setAllSettings(optionsFull);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Error} error
|
||||
*/
|
||||
_showSettingsImportError(error) {
|
||||
log.error(error);
|
||||
/** @type {HTMLElement} */
|
||||
const element = querySelectorNotNull(document, '#settings-import-error-message');
|
||||
element.textContent = `${error}`;
|
||||
if (this._settingsImportErrorModal !== null) {
|
||||
this._settingsImportErrorModal.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string>} warnings
|
||||
* @returns {Promise<import('backup-controller').ShowSettingsImportWarningsResult>}
|
||||
*/
|
||||
async _showSettingsImportWarnings(warnings) {
|
||||
const modal = this._settingsImportWarningModal;
|
||||
if (modal === null) { return {result: false}; }
|
||||
const buttons = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.settings-import-warning-import-button'));
|
||||
const messageContainer = document.querySelector('#settings-import-warning-message');
|
||||
if (buttons.length === 0 || messageContainer === null) {
|
||||
return {result: false};
|
||||
}
|
||||
|
||||
// Set message
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const warning of warnings) {
|
||||
const node = document.createElement('li');
|
||||
node.textContent = `${warning}`;
|
||||
fragment.appendChild(node);
|
||||
}
|
||||
messageContainer.textContent = '';
|
||||
messageContainer.appendChild(fragment);
|
||||
|
||||
// Show modal
|
||||
modal.setVisible(true);
|
||||
|
||||
// Wait for modal to close
|
||||
return new Promise((resolve) => {
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
const onButtonClick = (e) => {
|
||||
const element = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
e.preventDefault();
|
||||
complete({
|
||||
result: true,
|
||||
sanitize: element.dataset.importSanitize === 'true',
|
||||
});
|
||||
modal.setVisible(false);
|
||||
};
|
||||
/**
|
||||
* @param {import('panel-element').EventArgument<'visibilityChanged'>} details
|
||||
*/
|
||||
const onModalVisibilityChanged = ({visible}) => {
|
||||
if (visible) { return; }
|
||||
complete({result: false});
|
||||
};
|
||||
|
||||
let completed = false;
|
||||
/**
|
||||
* @param {import('backup-controller').ShowSettingsImportWarningsResult} result
|
||||
*/
|
||||
const complete = (result) => {
|
||||
if (completed) { return; }
|
||||
completed = true;
|
||||
|
||||
modal.off('visibilityChanged', onModalVisibilityChanged);
|
||||
for (const button of buttons) {
|
||||
button.removeEventListener('click', onButtonClick, false);
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
// Hook events
|
||||
modal.on('visibilityChanged', onModalVisibilityChanged);
|
||||
for (const button of buttons) {
|
||||
button.addEventListener('click', onButtonClick, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} urlString
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isLocalhostUrl(urlString) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
switch (url.hostname.toLowerCase()) {
|
||||
case 'localhost':
|
||||
case '127.0.0.1':
|
||||
case '[::1]':
|
||||
switch (url.protocol.toLowerCase()) {
|
||||
case 'http:':
|
||||
case 'https:':
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @param {boolean} dryRun
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_settingsImportSanitizeProfileOptions(options, dryRun) {
|
||||
const warnings = [];
|
||||
|
||||
const anki = options.anki;
|
||||
if (isObjectNotArray(anki)) {
|
||||
const fieldTemplates = anki.fieldTemplates;
|
||||
if (typeof fieldTemplates === 'string') {
|
||||
warnings.push('anki.fieldTemplates contains a non-default value');
|
||||
if (!dryRun) {
|
||||
anki.fieldTemplates = null;
|
||||
}
|
||||
}
|
||||
const server = anki.server;
|
||||
if (typeof server === 'string' && server.length > 0 && !this._isLocalhostUrl(server)) {
|
||||
warnings.push('anki.server uses a non-localhost URL');
|
||||
if (!dryRun) {
|
||||
anki.server = 'http://127.0.0.1:8765';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const audio = options.audio;
|
||||
if (isObjectNotArray(audio)) {
|
||||
const sources = audio.sources;
|
||||
if (Array.isArray(sources)) {
|
||||
for (let i = 0, ii = sources.length; i < ii; ++i) {
|
||||
const source = sources[i];
|
||||
if (!isObjectNotArray(source)) { continue; }
|
||||
const {url} = source;
|
||||
if (typeof url === 'string' && url.length > 0 && !this._isLocalhostUrl(url)) {
|
||||
warnings.push(`audio.sources[${i}].url uses a non-localhost URL`);
|
||||
if (!dryRun) {
|
||||
sources[i].url = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').Options} optionsFull
|
||||
* @param {boolean} dryRun
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
_settingsImportSanitizeOptions(optionsFull, dryRun) {
|
||||
const warnings = new Set();
|
||||
|
||||
const profiles = optionsFull.profiles;
|
||||
if (Array.isArray(profiles)) {
|
||||
for (const profile of profiles) {
|
||||
if (!isObjectNotArray(profile)) { continue; }
|
||||
const options = profile.options;
|
||||
if (!isObjectNotArray(options)) { continue; }
|
||||
|
||||
const warnings2 = this._settingsImportSanitizeProfileOptions(options, dryRun);
|
||||
for (const warning of warnings2) {
|
||||
warnings.add(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
*/
|
||||
async _importSettingsFile(file) {
|
||||
if (this._optionsUtil === null) { throw new Error('OptionsUtil invalid'); }
|
||||
|
||||
const dataString = arrayBufferUtf8Decode(await this._readFileArrayBuffer(file));
|
||||
/** @type {import('backup-controller').BackupData} */
|
||||
const data = parseJson(dataString);
|
||||
|
||||
// Type check
|
||||
if (!isObjectNotArray(data)) {
|
||||
throw new Error(`Invalid data type: ${typeof data}`);
|
||||
}
|
||||
|
||||
// Version check
|
||||
const version = data.version;
|
||||
if (!(
|
||||
typeof version === 'number' &&
|
||||
Number.isFinite(version) &&
|
||||
version === Math.floor(version)
|
||||
)) {
|
||||
throw new Error(`Invalid version: ${version}`);
|
||||
}
|
||||
|
||||
if (!(
|
||||
version >= 0 &&
|
||||
version <= this._currentVersion
|
||||
)) {
|
||||
throw new Error(`Unsupported version: ${version}`);
|
||||
}
|
||||
|
||||
// Verify options exists
|
||||
let optionsFull = data.options;
|
||||
if (!isObjectNotArray(optionsFull)) {
|
||||
throw new Error(`Invalid options type: ${typeof optionsFull}`);
|
||||
}
|
||||
|
||||
// Upgrade options
|
||||
optionsFull = await this._optionsUtil.update(optionsFull);
|
||||
|
||||
// Check for warnings
|
||||
const sanitizationWarnings = this._settingsImportSanitizeOptions(optionsFull, true);
|
||||
|
||||
// Show sanitization warnings
|
||||
if (sanitizationWarnings.size > 0) {
|
||||
const {result, sanitize} = await this._showSettingsImportWarnings(sanitizationWarnings);
|
||||
if (!result) { return; }
|
||||
|
||||
if (sanitize !== false) {
|
||||
this._settingsImportSanitizeOptions(optionsFull, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Update dictionaries
|
||||
await DictionaryController.ensureDictionarySettings(this._settingsController, void 0, optionsFull, false, false);
|
||||
|
||||
// Assign options
|
||||
await this._settingsImportSetOptionsFull(optionsFull);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onSettingsImportClick() {
|
||||
/** @type {HTMLElement} */
|
||||
const element = querySelectorNotNull(document, '#settings-import-file');
|
||||
element.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
async _onSettingsImportFileChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const files = element.files;
|
||||
if (files === null || files.length === 0) { return; }
|
||||
|
||||
const file = files[0];
|
||||
element.value = '';
|
||||
try {
|
||||
await this._importSettingsFile(file);
|
||||
} catch (error) {
|
||||
this._showSettingsImportError(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Resetting
|
||||
|
||||
/** */
|
||||
_onSettingsResetClick() {
|
||||
if (this._settingsResetModal === null) { return; }
|
||||
this._settingsResetModal.setVisible(true);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onSettingsResetConfirmClick() {
|
||||
if (this._optionsUtil === null) { throw new Error('OptionsUtil invalid'); }
|
||||
|
||||
if (this._settingsResetModal !== null) {
|
||||
this._settingsResetModal.setVisible(false);
|
||||
}
|
||||
|
||||
// Get default options
|
||||
const optionsFull = this._optionsUtil.getDefault();
|
||||
|
||||
// Update dictionaries
|
||||
await DictionaryController.ensureDictionarySettings(this._settingsController, void 0, optionsFull, false, false);
|
||||
|
||||
// Update display theme
|
||||
this._themeController.theme = optionsFull.profiles[optionsFull.profileCurrent].options.general.popupTheme;
|
||||
this._themeController.prepare();
|
||||
this._themeController.siteOverride = true;
|
||||
this._themeController.updateTheme();
|
||||
|
||||
// Assign options
|
||||
try {
|
||||
await this._settingsImportSetOptionsFull(optionsFull);
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Exporting Dictionaries Database
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {boolean} [isWarning]
|
||||
*/
|
||||
_databaseExportImportErrorMessage(message, isWarning = false) {
|
||||
/** @type {HTMLElement} */
|
||||
const errorMessageSettingsContainer = querySelectorNotNull(document, '#db-ops-error-report-container');
|
||||
errorMessageSettingsContainer.style.display = 'block';
|
||||
/** @type {HTMLElement} */
|
||||
const errorMessageContainer = querySelectorNotNull(document, '#db-ops-error-report');
|
||||
errorMessageContainer.style.display = 'block';
|
||||
errorMessageContainer.textContent = message;
|
||||
|
||||
if (isWarning) { // Hide after 5 seconds (5000 milliseconds)
|
||||
errorMessageContainer.style.color = '#FFC40C';
|
||||
setTimeout(function _hideWarningMessage() {
|
||||
errorMessageContainer.style.display = 'none';
|
||||
errorMessageContainer.style.color = '#8B0000';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{totalRows: number, completedRows: number, done: boolean}} details
|
||||
*/
|
||||
_databaseExportProgressCallback({totalRows, completedRows, done}) {
|
||||
log.log(`Progress: ${completedRows} of ${totalRows} rows completed`);
|
||||
/** @type {HTMLElement} */
|
||||
const messageSettingsContainer = querySelectorNotNull(document, '#db-ops-progress-report-container');
|
||||
messageSettingsContainer.style.display = 'block';
|
||||
/** @type {HTMLElement} */
|
||||
const messageContainer = querySelectorNotNull(document, '#db-ops-progress-report');
|
||||
messageContainer.style.display = 'block';
|
||||
messageContainer.textContent = `Export Progress: ${completedRows} of ${totalRows} rows completed`;
|
||||
|
||||
if (done) {
|
||||
log.log('Done exporting.');
|
||||
messageContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} databaseName
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
async _exportDatabase(databaseName) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const DexieConstructor = /** @type {import('dexie').DexieConstructor} */ (/** @type {unknown} */ (Dexie));
|
||||
const db = new DexieConstructor(databaseName);
|
||||
await db.open();
|
||||
/** @type {unknown} */
|
||||
// @ts-expect-error - The export function is declared as an extension which has no type information.
|
||||
const blob = await db.export({
|
||||
progressCallback: this._databaseExportProgressCallback.bind(this),
|
||||
});
|
||||
db.close();
|
||||
return /** @type {Blob} */ (blob);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onSettingsExportDatabaseClick() {
|
||||
if (this._settingsExportDatabaseToken !== null) {
|
||||
// An existing import or export is in progress.
|
||||
this._databaseExportImportErrorMessage('An export or import operation is already in progress. Please wait till it is over.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const errorMessageContainer = querySelectorNotNull(document, '#db-ops-error-report');
|
||||
errorMessageContainer.style.display = 'none';
|
||||
|
||||
const date = new Date(Date.now());
|
||||
const pageExitPrevention = this._settingsController.preventPageExit();
|
||||
try {
|
||||
/** @type {import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._settingsExportDatabaseToken = token;
|
||||
const fileName = `yomitan-dictionaries-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`;
|
||||
const data = await this._exportDatabase(this._dictionariesDatabaseName);
|
||||
const blob = new Blob([data], {type: 'application/json'});
|
||||
this._saveBlob(blob, fileName);
|
||||
} catch (error) {
|
||||
log.log(error);
|
||||
this._databaseExportImportErrorMessage('Errors encountered while exporting. Please try again. Restart the browser if it continues to fail.');
|
||||
} finally {
|
||||
pageExitPrevention.end();
|
||||
this._settingsExportDatabaseToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Importing Dictionaries Database
|
||||
|
||||
/**
|
||||
* @param {{totalRows: number, completedRows: number, done: boolean}} details
|
||||
*/
|
||||
_databaseImportProgressCallback({totalRows, completedRows, done}) {
|
||||
log.log(`Progress: ${completedRows} of ${totalRows} rows completed`);
|
||||
/** @type {HTMLElement} */
|
||||
const messageSettingsContainer = querySelectorNotNull(document, '#db-ops-progress-report-container');
|
||||
messageSettingsContainer.style.display = 'block';
|
||||
/** @type {HTMLElement} */
|
||||
const messageContainer = querySelectorNotNull(document, '#db-ops-progress-report');
|
||||
messageContainer.style.display = 'block';
|
||||
messageContainer.style.color = '#4169e1';
|
||||
messageContainer.textContent = `Import Progress: ${completedRows} of ${totalRows} rows completed`;
|
||||
|
||||
if (done) {
|
||||
log.log('Done importing.');
|
||||
messageContainer.style.color = '#006633';
|
||||
messageContainer.textContent = 'Done importing. You will need to re-enable the dictionaries and refresh afterward. If you run into issues, please restart the browser. If it continues to fail, reinstall Yomitan and import dictionaries one-by-one.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} _databaseName
|
||||
* @param {File} file
|
||||
*/
|
||||
async _importDatabase(_databaseName, file) {
|
||||
await this._settingsController.application.api.purgeDatabase();
|
||||
await Dexie.import(file, {
|
||||
progressCallback: this._databaseImportProgressCallback.bind(this),
|
||||
});
|
||||
void this._settingsController.application.api.triggerDatabaseUpdated('dictionary', 'import');
|
||||
this._settingsController.application.triggerStorageChanged();
|
||||
}
|
||||
|
||||
/** */
|
||||
_onSettingsImportDatabaseClick() {
|
||||
/** @type {HTMLElement} */
|
||||
const element = querySelectorNotNull(document, '#settings-import-db');
|
||||
element.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
async _onSettingsImportDatabaseChange(e) {
|
||||
if (this._settingsExportDatabaseToken !== null) {
|
||||
// An existing import or export is in progress.
|
||||
this._databaseExportImportErrorMessage('An export or import operation is already in progress. Please wait till it is over.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const errorMessageContainer = querySelectorNotNull(document, '#db-ops-error-report');
|
||||
errorMessageContainer.style.display = 'none';
|
||||
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const files = element.files;
|
||||
if (files === null || files.length === 0) { return; }
|
||||
|
||||
const pageExitPrevention = this._settingsController.preventPageExit();
|
||||
const file = files[0];
|
||||
element.value = '';
|
||||
try {
|
||||
/** @type {import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._settingsExportDatabaseToken = token;
|
||||
await this._importDatabase(this._dictionariesDatabaseName, file);
|
||||
} catch (error) {
|
||||
log.log(error);
|
||||
/** @type {HTMLElement} */
|
||||
const messageContainer = querySelectorNotNull(document, '#db-ops-progress-report');
|
||||
messageContainer.style.color = 'red';
|
||||
this._databaseExportImportErrorMessage('Encountered errors when importing. Please restart the browser and try again. If it continues to fail, reinstall Yomitan and import dictionaries one-by-one.');
|
||||
} finally {
|
||||
pageExitPrevention.end();
|
||||
this._settingsExportDatabaseToken = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
224
vendor/yomitan/js/pages/settings/collapsible-dictionary-controller.js
vendored
Normal file
224
vendor/yomitan/js/pages/settings/collapsible-dictionary-controller.js
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class CollapsibleDictionaryController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._getDictionaryInfoToken = null;
|
||||
/** @type {Map<string, import('dictionary-importer').Summary>} */
|
||||
this._dictionaryInfoMap = new Map();
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {HTMLElement} */
|
||||
this._container = querySelectorNotNull(document, '#collapsible-dictionary-list');
|
||||
/** @type {HTMLSelectElement[]} */
|
||||
this._selects = [];
|
||||
/** @type {?HTMLSelectElement} */
|
||||
this._allSelect = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
await this._onDatabaseUpdated();
|
||||
|
||||
this._settingsController.application.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
this._settingsController.on('dictionarySettingsReordered', this._onDictionarySettingsReordered.bind(this));
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _onDatabaseUpdated() {
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._getDictionaryInfoToken = token;
|
||||
const dictionaries = await this._settingsController.getDictionaryInfo();
|
||||
if (this._getDictionaryInfoToken !== token) { return; }
|
||||
this._getDictionaryInfoToken = null;
|
||||
|
||||
this._dictionaryInfoMap.clear();
|
||||
for (const entry of dictionaries) {
|
||||
this._dictionaryInfoMap.set(entry.title, entry);
|
||||
}
|
||||
|
||||
await this._onDictionarySettingsReordered();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._selects = [];
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
this._setupAllSelect(fragment, options);
|
||||
|
||||
const {dictionaries} = options;
|
||||
for (let i = 0, ii = dictionaries.length; i < ii; ++i) {
|
||||
const {name} = dictionaries[i];
|
||||
const dictionaryInfo = this._dictionaryInfoMap.get(name);
|
||||
if (!dictionaryInfo?.counts?.terms?.total && !dictionaryInfo?.counts?.kanji?.total) {
|
||||
continue;
|
||||
}
|
||||
if (typeof dictionaryInfo === 'undefined') { continue; }
|
||||
|
||||
const select = this._addSelect(fragment, name, `rev.${dictionaryInfo.revision}`);
|
||||
select.dataset.setting = `dictionaries[${i}].definitionsCollapsible`;
|
||||
this._eventListeners.addEventListener(select, 'settingChanged', this._onDefinitionsCollapsibleChange.bind(this), false);
|
||||
|
||||
this._selects.push(select);
|
||||
}
|
||||
|
||||
const container = /** @type {HTMLElement} */ (this._container);
|
||||
container.textContent = '';
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onDefinitionsCollapsibleChange() {
|
||||
void this._updateAllSelectFresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onAllSelectChange(e) {
|
||||
const {value} = /** @type {HTMLSelectElement} */ (e.currentTarget);
|
||||
const value2 = this._normalizeDictionaryDefinitionsCollapsible(value);
|
||||
if (value2 === null) { return; }
|
||||
void this._setDefinitionsCollapsibleAll(value2);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onDictionarySettingsReordered() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DocumentFragment} fragment
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
*/
|
||||
_setupAllSelect(fragment, options) {
|
||||
const select = this._addSelect(fragment, 'All', '');
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = 'varies';
|
||||
option.textContent = 'Varies';
|
||||
option.disabled = true;
|
||||
select.appendChild(option);
|
||||
|
||||
this._eventListeners.addEventListener(select, 'change', this._onAllSelectChange.bind(this), false);
|
||||
|
||||
this._allSelect = select;
|
||||
this._updateAllSelect(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DocumentFragment} fragment
|
||||
* @param {string} dictionary
|
||||
* @param {string} version
|
||||
* @returns {HTMLSelectElement}
|
||||
*/
|
||||
_addSelect(fragment, dictionary, version) {
|
||||
const node = this._settingsController.instantiateTemplate('collapsible-dictionary-item');
|
||||
fragment.appendChild(node);
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const nameNode = querySelectorNotNull(node, '.dictionary-title');
|
||||
nameNode.textContent = dictionary;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const versionNode = querySelectorNotNull(node, '.dictionary-revision');
|
||||
versionNode.textContent = version;
|
||||
|
||||
return querySelectorNotNull(node, '.definitions-collapsible');
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateAllSelectFresh() {
|
||||
this._updateAllSelect(await this._settingsController.getOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
*/
|
||||
_updateAllSelect(options) {
|
||||
let value = null;
|
||||
let varies = false;
|
||||
for (const {definitionsCollapsible} of options.dictionaries) {
|
||||
if (value === null) {
|
||||
value = definitionsCollapsible;
|
||||
} else if (value !== definitionsCollapsible) {
|
||||
varies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._allSelect !== null) {
|
||||
this._allSelect.value = (varies || value === null ? 'varies' : value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').DictionaryDefinitionsCollapsible} value
|
||||
*/
|
||||
async _setDefinitionsCollapsibleAll(value) {
|
||||
const options = await this._settingsController.getOptions();
|
||||
/** @type {import('settings-modifications').Modification[]} */
|
||||
const targets = [];
|
||||
const {dictionaries} = options;
|
||||
for (let i = 0, ii = dictionaries.length; i < ii; ++i) {
|
||||
const path = `dictionaries[${i}].definitionsCollapsible`;
|
||||
targets.push({action: 'set', path, value});
|
||||
}
|
||||
await this._settingsController.modifyProfileSettings(targets);
|
||||
for (const select of this._selects) {
|
||||
select.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {?import('settings').DictionaryDefinitionsCollapsible}
|
||||
*/
|
||||
_normalizeDictionaryDefinitionsCollapsible(value) {
|
||||
switch (value) {
|
||||
case 'not-collapsible':
|
||||
case 'expanded':
|
||||
case 'collapsed':
|
||||
case 'force-collapsed':
|
||||
case 'force-expanded':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
vendor/yomitan/js/pages/settings/data-transmission-consent-controller.js
vendored
Normal file
65
vendor/yomitan/js/pages/settings/data-transmission-consent-controller.js
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Yomitan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {ModalController} from './modal-controller.js';
|
||||
|
||||
export class DataTransmissionConsentController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {ModalController} modalController
|
||||
*/
|
||||
constructor(settingsController, modalController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {?HTMLButtonElement} */
|
||||
this._acceptDataTransmissionButton = null;
|
||||
/** @type {?HTMLButtonElement} */
|
||||
this._declineDataTransmissionButton = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const firefoxDataTransmissionModal = this._modalController.getModal('firefox-data-transmission-consent');
|
||||
|
||||
if (firefoxDataTransmissionModal) {
|
||||
this._acceptDataTransmissionButton = /** @type {HTMLButtonElement} */ (querySelectorNotNull(document, '#accept-data-transmission'));
|
||||
this._declineDataTransmissionButton = /** @type {HTMLButtonElement} */ (querySelectorNotNull(document, '#decline-data-transmission'));
|
||||
|
||||
this._acceptDataTransmissionButton.addEventListener('click', this._onAccept.bind(this));
|
||||
this._declineDataTransmissionButton.addEventListener('click', this._onDecline.bind(this));
|
||||
|
||||
const options = await this._settingsController.getOptionsFull();
|
||||
firefoxDataTransmissionModal?.setVisible(!options.global.dataTransmissionConsentShown);
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _onAccept() {
|
||||
await this._settingsController.setGlobalSetting('global.dataTransmissionConsentShown', true);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onDecline() {
|
||||
await this._settingsController.setGlobalSetting('global.dataTransmissionConsentShown', true);
|
||||
await this._settingsController.setProfileSetting('audio.enabled', false);
|
||||
}
|
||||
}
|
||||
1475
vendor/yomitan/js/pages/settings/dictionary-controller.js
vendored
Normal file
1475
vendor/yomitan/js/pages/settings/dictionary-controller.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
943
vendor/yomitan/js/pages/settings/dictionary-import-controller.js
vendored
Normal file
943
vendor/yomitan/js/pages/settings/dictionary-import-controller.js
vendored
Normal file
@@ -0,0 +1,943 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ExtensionError} from '../../core/extension-error.js';
|
||||
import {readResponseJson} from '../../core/json.js';
|
||||
import {log} from '../../core/log.js';
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {getKebabCase} from '../../data/anki-template-util.js';
|
||||
import {DictionaryWorker} from '../../dictionary/dictionary-worker.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {DictionaryController} from './dictionary-controller.js';
|
||||
|
||||
export class DictionaryImportController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {import('./modal-controller.js').ModalController} modalController
|
||||
* @param {import('./status-footer.js').StatusFooter} statusFooter
|
||||
*/
|
||||
constructor(settingsController, modalController, statusFooter) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {import('./modal-controller.js').ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {import('./status-footer.js').StatusFooter} */
|
||||
this._statusFooter = statusFooter;
|
||||
/** @type {boolean} */
|
||||
this._modifying = false;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._purgeButton = querySelectorNotNull(document, '#dictionary-delete-all-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._purgeConfirmButton = querySelectorNotNull(document, '#dictionary-confirm-delete-all-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._importFileInput = querySelectorNotNull(document, '#dictionary-import-file-input');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._importFileDrop = querySelectorNotNull(document, '#dictionary-drop-file-zone');
|
||||
/** @type {number} */
|
||||
this._importFileDropItemCount = 0;
|
||||
/** @type {HTMLInputElement} */
|
||||
this._importButton = querySelectorNotNull(document, '#dictionary-import-button');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._importURLButton = querySelectorNotNull(document, '#dictionary-import-url-button');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._importURLText = querySelectorNotNull(document, '#dictionary-import-url-text');
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._purgeConfirmModal = null;
|
||||
/** @type {HTMLElement} */
|
||||
this._errorContainer = querySelectorNotNull(document, '#dictionary-error');
|
||||
/** @type {[originalMessage: string, newMessage: string][]} */
|
||||
this._errorToStringOverrides = [
|
||||
[
|
||||
'A mutation operation was attempted on a database that did not allow mutations.',
|
||||
'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.',
|
||||
],
|
||||
[
|
||||
'The operation failed for reasons unrelated to the database itself and not covered by any other error code.',
|
||||
'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.',
|
||||
],
|
||||
];
|
||||
/** @type {string[]} */
|
||||
this._recommendedDictionaryQueue = [];
|
||||
/** @type {boolean} */
|
||||
this._recommendedDictionaryActiveImport = false;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._importModal = this._modalController.getModal('dictionary-import');
|
||||
this._purgeConfirmModal = this._modalController.getModal('dictionary-confirm-delete-all');
|
||||
|
||||
this._purgeButton.addEventListener('click', this._onPurgeButtonClick.bind(this), false);
|
||||
this._purgeConfirmButton.addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false);
|
||||
this._importButton.addEventListener('click', this._onImportButtonClick.bind(this), false);
|
||||
this._importURLButton.addEventListener('click', this._onImportFromURL.bind(this), false);
|
||||
this._importFileInput.addEventListener('change', this._onImportFileChange.bind(this), false);
|
||||
|
||||
this._importFileDrop.addEventListener('click', this._onImportFileButtonClick.bind(this), false);
|
||||
this._importFileDrop.addEventListener('dragenter', this._onFileDropEnter.bind(this), false);
|
||||
this._importFileDrop.addEventListener('dragover', this._onFileDropOver.bind(this), false);
|
||||
this._importFileDrop.addEventListener('dragleave', this._onFileDropLeave.bind(this), false);
|
||||
this._importFileDrop.addEventListener('drop', this._onFileDrop.bind(this), false);
|
||||
|
||||
this._settingsController.on('importDictionaryFromUrl', this._onEventImportDictionaryFromUrl.bind(this));
|
||||
|
||||
const recommendedDictionaryButton = document.querySelector('[data-modal-action="show,recommended-dictionaries"]');
|
||||
if (recommendedDictionaryButton) {
|
||||
recommendedDictionaryButton.addEventListener('click', this._renderRecommendedDictionaries.bind(this), false);
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
async _onRecommendedImportClick(e) {
|
||||
if (!e.target || !(e.target instanceof HTMLButtonElement)) { return; }
|
||||
|
||||
const import_url = e.target.attributes.getNamedItem('data-import-url');
|
||||
if (!import_url) { return; }
|
||||
this._recommendedDictionaryQueue.push(import_url.value);
|
||||
|
||||
e.target.disabled = true;
|
||||
|
||||
if (this._recommendedDictionaryActiveImport) { return; }
|
||||
|
||||
while (this._recommendedDictionaryQueue.length > 0) {
|
||||
this._recommendedDictionaryActiveImport = true;
|
||||
try {
|
||||
const url = this._recommendedDictionaryQueue[0];
|
||||
if (!url) { continue; }
|
||||
|
||||
const importProgressTracker = new ImportProgressTracker(this._getUrlImportSteps(), 1);
|
||||
const onProgress = importProgressTracker.onProgress.bind(importProgressTracker);
|
||||
await this._importDictionaries(
|
||||
this._generateFilesFromUrls([url], onProgress),
|
||||
null,
|
||||
null,
|
||||
importProgressTracker,
|
||||
);
|
||||
void this._recommendedDictionaryQueue.shift();
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
this._recommendedDictionaryActiveImport = false;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _renderRecommendedDictionaries() {
|
||||
const url = '../../data/recommended-dictionaries.json';
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
mode: 'no-cors',
|
||||
cache: 'default',
|
||||
credentials: 'omit',
|
||||
redirect: 'follow',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
|
||||
/** @type {import('dictionary-recommended.js').RecommendedDictionaryElementMap[]} */
|
||||
const recommendedDictionaryCategories = [
|
||||
{property: 'terms', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-term-dictionaries'), '.recommended-dictionary-list')},
|
||||
{property: 'kanji', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-kanji-dictionaries'), '.recommended-dictionary-list')},
|
||||
{property: 'frequency', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-frequency-dictionaries'), '.recommended-dictionary-list')},
|
||||
{property: 'grammar', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-grammar-dictionaries'), '.recommended-dictionary-list')},
|
||||
{property: 'pronunciation', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-pronunciation-dictionaries'), '.recommended-dictionary-list')},
|
||||
];
|
||||
|
||||
const language = (await this._settingsController.getOptions()).general.language;
|
||||
/** @type {import('dictionary-recommended.js').RecommendedDictionaries} */
|
||||
const recommendedDictionaries = (await readResponseJson(response));
|
||||
|
||||
if (!(language in recommendedDictionaries)) {
|
||||
for (const {element} of recommendedDictionaryCategories) {
|
||||
const dictionaryCategoryParent = element.parentElement;
|
||||
if (dictionaryCategoryParent) {
|
||||
dictionaryCategoryParent.hidden = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const installedDictionaries = await this._settingsController.getDictionaryInfo();
|
||||
/** @type {Set<string>} */
|
||||
const installedDictionaryNames = new Set();
|
||||
/** @type {Set<string>} */
|
||||
const installedDictionaryDownloadUrls = new Set();
|
||||
for (const dictionary of installedDictionaries) {
|
||||
installedDictionaryNames.add(dictionary.title);
|
||||
if (dictionary.downloadUrl) {
|
||||
installedDictionaryDownloadUrls.add(dictionary.downloadUrl);
|
||||
}
|
||||
}
|
||||
|
||||
for (const {property, element} of recommendedDictionaryCategories) {
|
||||
this._renderRecommendedDictionaryGroup(recommendedDictionaries[language][property], element, installedDictionaryNames, installedDictionaryDownloadUrls);
|
||||
}
|
||||
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const buttons = document.querySelectorAll('.action-button[data-action=import-recommended-dictionary]');
|
||||
for (const button of buttons) {
|
||||
button.addEventListener('click', this._onRecommendedImportClick.bind(this), false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('dictionary-recommended.js').RecommendedDictionary[]} recommendedDictionaries
|
||||
* @param {HTMLElement} dictionariesList
|
||||
* @param {Set<string>} installedDictionaryNames
|
||||
* @param {Set<string>} installedDictionaryDownloadUrls
|
||||
*/
|
||||
_renderRecommendedDictionaryGroup(recommendedDictionaries, dictionariesList, installedDictionaryNames, installedDictionaryDownloadUrls) {
|
||||
const dictionariesListParent = dictionariesList.parentElement;
|
||||
dictionariesList.innerHTML = '';
|
||||
// Hide section if no dictionaries are available
|
||||
if (dictionariesListParent) {
|
||||
dictionariesListParent.hidden = recommendedDictionaries.length === 0;
|
||||
}
|
||||
for (const dictionary of recommendedDictionaries) {
|
||||
if (dictionariesList) {
|
||||
if (dictionariesListParent) {
|
||||
dictionariesListParent.hidden = false;
|
||||
}
|
||||
const template = this._settingsController.instantiateTemplate('recommended-dictionaries-list-item');
|
||||
const label = querySelectorNotNull(template, '.settings-item-label');
|
||||
const description = querySelectorNotNull(template, '.description');
|
||||
/** @type {HTMLAnchorElement} */
|
||||
const homepage = querySelectorNotNull(template, '.homepage');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const button = querySelectorNotNull(template, '.action-button[data-action=import-recommended-dictionary]');
|
||||
button.disabled = (
|
||||
installedDictionaryNames.has(dictionary.name) ||
|
||||
installedDictionaryDownloadUrls.has(dictionary.downloadUrl) ||
|
||||
this._recommendedDictionaryQueue.includes(dictionary.downloadUrl)
|
||||
);
|
||||
|
||||
const urlAttribute = document.createAttribute('data-import-url');
|
||||
urlAttribute.value = dictionary.downloadUrl;
|
||||
button.attributes.setNamedItem(urlAttribute);
|
||||
|
||||
label.textContent = dictionary.name;
|
||||
description.textContent = dictionary.description;
|
||||
if (dictionary.homepage) {
|
||||
homepage.target = '_blank';
|
||||
homepage.href = dictionary.homepage;
|
||||
} else {
|
||||
homepage.remove();
|
||||
}
|
||||
|
||||
dictionariesList.append(template);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'importDictionaryFromUrl'>} details
|
||||
*/
|
||||
_onEventImportDictionaryFromUrl({url, profilesDictionarySettings, onImportDone}) {
|
||||
void this.importFilesFromURLs(url, profilesDictionarySettings, onImportDone);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onImportFileButtonClick() {
|
||||
/** @type {HTMLInputElement} */ (this._importFileInput).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} e
|
||||
*/
|
||||
_onFileDropEnter(e) {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer) { return; }
|
||||
for (const item of e.dataTransfer.items) {
|
||||
// Directories and files with no extension both show as ''
|
||||
if (item.type === '' || item.type === 'application/zip') {
|
||||
this._importFileDrop.classList.add('drag-over');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} e
|
||||
*/
|
||||
_onFileDropOver(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} e
|
||||
*/
|
||||
_onFileDropLeave(e) {
|
||||
e.preventDefault();
|
||||
this._importFileDrop.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} e
|
||||
*/
|
||||
async _onFileDrop(e) {
|
||||
e.preventDefault();
|
||||
this._importFileDrop.classList.remove('drag-over');
|
||||
if (e.dataTransfer === null) { return; }
|
||||
/** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(false);
|
||||
/** @type {File[]} */
|
||||
const fileArray = [];
|
||||
for (const fileEntry of await this._getAllFileEntries(e.dataTransfer.items)) {
|
||||
if (!fileEntry) { return; }
|
||||
try {
|
||||
fileArray.push(await new Promise((resolve, reject) => { fileEntry.file(resolve, reject); }));
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
const importProgressTracker = new ImportProgressTracker(this._getFileImportSteps(), fileArray.length);
|
||||
void this._importDictionaries(
|
||||
this._arrayToAsyncGenerator(fileArray),
|
||||
null,
|
||||
null,
|
||||
importProgressTracker,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DataTransferItemList} dataTransferItemList
|
||||
* @returns {Promise<FileSystemFileEntry[]>}
|
||||
*/
|
||||
async _getAllFileEntries(dataTransferItemList) {
|
||||
/** @type {(FileSystemFileEntry)[]} */
|
||||
const fileEntries = [];
|
||||
/** @type {(FileSystemEntry | null)[]} */
|
||||
const entries = [];
|
||||
for (let i = 0; i < dataTransferItemList.length; i++) {
|
||||
entries.push(dataTransferItemList[i].webkitGetAsEntry());
|
||||
}
|
||||
this._importFileDropItemCount = entries.length - 1;
|
||||
while (entries.length > 0) {
|
||||
this._importFileDropItemCount += 1;
|
||||
this._validateDirectoryItemCount();
|
||||
|
||||
/** @type {(FileSystemEntry | null) | undefined} */
|
||||
const entry = entries.shift();
|
||||
if (!entry) { continue; }
|
||||
if (entry.isFile) {
|
||||
if (entry.name.substring(entry.name.lastIndexOf('.'), entry.name.length) === '.zip') {
|
||||
// @ts-expect-error - ts does not recognize `if (entry.isFile)` as verifying `entry` is type `FileSystemFileEntry` and instanceof does not work
|
||||
fileEntries.push(entry);
|
||||
}
|
||||
} else if (entry.isDirectory) {
|
||||
// @ts-expect-error - ts does not recognize `if (entry.isDirectory)` as verifying `entry` is type `FileSystemDirectoryEntry` and instanceof does not work
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
entries.push(...await this._readAllDirectoryEntries(entry.createReader()));
|
||||
}
|
||||
}
|
||||
return fileEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FileSystemDirectoryReader} directoryReader
|
||||
* @returns {Promise<(FileSystemEntry)[]>}
|
||||
*/
|
||||
async _readAllDirectoryEntries(directoryReader) {
|
||||
const entries = [];
|
||||
/** @type {(FileSystemEntry)[]} */
|
||||
let readEntries = await new Promise((resolve) => { directoryReader.readEntries(resolve); });
|
||||
while (readEntries.length > 0) {
|
||||
this._importFileDropItemCount += readEntries.length;
|
||||
this._validateDirectoryItemCount();
|
||||
|
||||
entries.push(...readEntries);
|
||||
readEntries = await new Promise((resolve) => { directoryReader.readEntries(resolve); });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws
|
||||
*/
|
||||
_validateDirectoryItemCount() {
|
||||
if (this._importFileDropItemCount > 1000) {
|
||||
this._importFileDropItemCount = 0;
|
||||
const errorText = 'Directory upload item count too large';
|
||||
this._showErrors([new Error(errorText)]);
|
||||
throw new Error(errorText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onImportButtonClick(e) {
|
||||
e.preventDefault();
|
||||
/** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onPurgeButtonClick(e) {
|
||||
e.preventDefault();
|
||||
/** @type {import('./modal.js').Modal} */ (this._purgeConfirmModal).setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onPurgeConfirmButtonClick(e) {
|
||||
e.preventDefault();
|
||||
/** @type {import('./modal.js').Modal} */ (this._purgeConfirmModal).setVisible(false);
|
||||
void this._purgeDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
async _onImportFileChange(e) {
|
||||
/** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(false);
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const {files} = node;
|
||||
if (files === null) { return; }
|
||||
const files2 = [...files];
|
||||
node.value = '';
|
||||
void this._importDictionaries(
|
||||
this._arrayToAsyncGenerator(files2),
|
||||
null,
|
||||
null,
|
||||
new ImportProgressTracker(this._getFileImportSteps(), files2.length),
|
||||
);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onImportFromURL() {
|
||||
const text = this._importURLText.value.trim();
|
||||
if (!text) { return; }
|
||||
await this.importFilesFromURLs(text, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
|
||||
* @param {import('settings-controller').ImportDictionaryDoneCallback} onImportDone
|
||||
*/
|
||||
async importFilesFromURLs(text, profilesDictionarySettings, onImportDone) {
|
||||
const urls = text.split('\n');
|
||||
|
||||
const importProgressTracker = new ImportProgressTracker(this._getUrlImportSteps(), urls.length);
|
||||
const onProgress = importProgressTracker.onProgress.bind(importProgressTracker);
|
||||
void this._importDictionaries(
|
||||
this._generateFilesFromUrls(urls, onProgress),
|
||||
profilesDictionarySettings,
|
||||
onImportDone,
|
||||
importProgressTracker,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} urls
|
||||
* @param {import('dictionary-worker').ImportProgressCallback} onProgress
|
||||
* @yields {Promise<File>}
|
||||
* @returns {AsyncGenerator<File, void, void>}
|
||||
*/
|
||||
async *_generateFilesFromUrls(urls, onProgress) {
|
||||
for (const url of urls) {
|
||||
onProgress({nextStep: true, index: 0, count: 0});
|
||||
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url.trim(), true);
|
||||
xhr.responseType = 'blob';
|
||||
|
||||
xhr.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress({nextStep: false, index: event.loaded, count: event.total});
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {Promise<File>} */
|
||||
const blobPromise = new Promise((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
if (xhr.response instanceof Blob) {
|
||||
resolve(new File([xhr.response], 'fileFromURL'));
|
||||
} else {
|
||||
reject(new Error(`Failed to fetch blob from ${url}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Failed to fetch the URL: ${url}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error(`Error fetching URL: ${url}`));
|
||||
};
|
||||
});
|
||||
|
||||
xhr.send();
|
||||
|
||||
const file = await blobPromise;
|
||||
yield file;
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _purgeDatabase() {
|
||||
if (this._modifying) { return; }
|
||||
|
||||
const prevention = this._preventPageExit();
|
||||
|
||||
try {
|
||||
this._setModifying(true);
|
||||
this._hideErrors();
|
||||
|
||||
await this._settingsController.application.api.purgeDatabase();
|
||||
const errors = await this._clearDictionarySettings();
|
||||
|
||||
if (errors.length > 0) {
|
||||
this._showErrors(errors);
|
||||
}
|
||||
} catch (error) {
|
||||
this._showErrors([toError(error)]);
|
||||
} finally {
|
||||
prevention.end();
|
||||
this._setModifying(false);
|
||||
this._triggerStorageChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AsyncGenerator<File, void, void>} dictionaries
|
||||
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
|
||||
* @param {import('settings-controller').ImportDictionaryDoneCallback} onImportDone
|
||||
* @param {ImportProgressTracker} importProgressTracker
|
||||
*/
|
||||
async _importDictionaries(dictionaries, profilesDictionarySettings, onImportDone, importProgressTracker) {
|
||||
if (this._modifying) { return; }
|
||||
|
||||
const statusFooter = this._statusFooter;
|
||||
const progressSelector = '.dictionary-import-progress';
|
||||
const progressContainers = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`#dictionaries-modal ${progressSelector}`));
|
||||
const recommendedProgressContainers = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`#recommended-dictionaries-modal ${progressSelector}`));
|
||||
|
||||
const prevention = this._preventPageExit();
|
||||
|
||||
const onProgress = importProgressTracker.onProgress.bind(importProgressTracker);
|
||||
|
||||
/** @type {Error[]} */
|
||||
let errors = [];
|
||||
try {
|
||||
this._setModifying(true);
|
||||
this._hideErrors();
|
||||
|
||||
for (const progress of [...progressContainers, ...recommendedProgressContainers]) { progress.hidden = false; }
|
||||
|
||||
const optionsFull = await this._settingsController.getOptionsFull();
|
||||
const importDetails = {
|
||||
prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported,
|
||||
yomitanVersion: chrome.runtime.getManifest().version,
|
||||
};
|
||||
|
||||
for (let i = 0; i < importProgressTracker.dictionaryCount; ++i) {
|
||||
importProgressTracker.onNextDictionary();
|
||||
if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, true); }
|
||||
const file = (await dictionaries.next()).value;
|
||||
if (!file || !(file instanceof File)) {
|
||||
errors.push(new Error(`Failed to read file ${i + 1} of ${importProgressTracker.dictionaryCount}.`));
|
||||
continue;
|
||||
}
|
||||
errors = [
|
||||
...errors,
|
||||
...(await this._importDictionaryFromZip(
|
||||
file,
|
||||
profilesDictionarySettings,
|
||||
importDetails,
|
||||
onProgress,
|
||||
) ?? []),
|
||||
];
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(toError(error));
|
||||
} finally {
|
||||
this._showErrors(errors);
|
||||
prevention.end();
|
||||
for (const progress of [...progressContainers, ...recommendedProgressContainers]) { progress.hidden = true; }
|
||||
if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, false); }
|
||||
this._setModifying(false);
|
||||
this._triggerStorageChanged();
|
||||
if (onImportDone) { onImportDone(); }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('dictionary-importer').ImportSteps}
|
||||
*/
|
||||
_getFileImportSteps() {
|
||||
return [
|
||||
{label: '', callback: this._triggerStorageChanged.bind(this)}, // Dictionary import is uninitialized
|
||||
{label: 'Initializing import'}, // Dictionary import is uninitialized
|
||||
{label: 'Loading dictionary'}, // Load dictionary archive and validate index
|
||||
{label: 'Loading schemas'}, // Load schemas and get archive files
|
||||
{label: 'Validating data'}, // Load and validate dictionary data
|
||||
{label: 'Importing data'}, // Add dictionary descriptor, load, and import data
|
||||
{label: 'Finalizing import', callback: this._triggerStorageChanged.bind(this)}, // Update dictionary descriptor
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('dictionary-importer').ImportSteps}
|
||||
*/
|
||||
_getUrlImportSteps() {
|
||||
const urlImportSteps = this._getFileImportSteps();
|
||||
urlImportSteps.splice(2, 0, {label: 'Downloading dictionary'});
|
||||
return urlImportSteps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @yields {Promise<T>}
|
||||
* @returns {AsyncGenerator<T, void, void>}
|
||||
*/
|
||||
async *_arrayToAsyncGenerator(arr) {
|
||||
for (const item of arr) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
|
||||
* @param {import('dictionary-importer').ImportDetails} importDetails
|
||||
* @param {import('dictionary-worker').ImportProgressCallback} onProgress
|
||||
* @returns {Promise<Error[] | undefined>}
|
||||
*/
|
||||
async _importDictionaryFromZip(file, profilesDictionarySettings, importDetails, onProgress) {
|
||||
const archiveContent = await this._readFile(file);
|
||||
const {result, errors} = await new DictionaryWorker().importDictionary(archiveContent, importDetails, onProgress);
|
||||
if (!result) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const errors2 = await this._addDictionarySettings(result, profilesDictionarySettings);
|
||||
|
||||
await this._settingsController.application.api.triggerDatabaseUpdated('dictionary', 'import');
|
||||
|
||||
// Only runs if updating a dictionary
|
||||
if (profilesDictionarySettings !== null) {
|
||||
const options = await this._settingsController.getOptionsFull();
|
||||
const {profiles} = options;
|
||||
|
||||
for (const profile of profiles) {
|
||||
for (const cardFormat of profile.options.anki.cardFormats) {
|
||||
const ankiTermFields = cardFormat.fields;
|
||||
const oldFieldSegmentRegex = new RegExp(getKebabCase(profilesDictionarySettings[profile.id].name), 'g');
|
||||
const newFieldSegment = getKebabCase(result.title);
|
||||
for (const key of Object.keys(ankiTermFields)) {
|
||||
ankiTermFields[key].value = ankiTermFields[key].value.replace(oldFieldSegmentRegex, newFieldSegment);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this._settingsController.setAllSettings(options);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.push(new Error(`Dictionary may not have been imported properly: ${errors.length} error${errors.length === 1 ? '' : 's'} reported.`));
|
||||
this._showErrors([...errors, ...errors2]);
|
||||
} else if (errors2.length > 0) {
|
||||
this._showErrors(errors2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-importer').Summary} summary
|
||||
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
|
||||
* @returns {Promise<Error[]>}
|
||||
*/
|
||||
async _addDictionarySettings(summary, profilesDictionarySettings) {
|
||||
const {title, sequenced, styles} = summary;
|
||||
let optionsFull;
|
||||
// Workaround Firefox bug sometimes causing getOptionsFull to fail
|
||||
for (let i = 0, success = false; (i < 10) && (success === false); i++) {
|
||||
try {
|
||||
optionsFull = await this._settingsController.getOptionsFull();
|
||||
success = true;
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
if (!optionsFull) { return [new Error('Failed to automatically set dictionary settings. A page refresh and manual enabling of the dictionary may be required.')]; }
|
||||
|
||||
const profileIndex = this._settingsController.profileIndex;
|
||||
/** @type {import('settings-modifications').Modification[]} */
|
||||
const targets = [];
|
||||
const profileCount = optionsFull.profiles.length;
|
||||
for (let i = 0; i < profileCount; ++i) {
|
||||
const {options, id: profileId} = optionsFull.profiles[i];
|
||||
const enabled = profileIndex === i;
|
||||
const defaultSettings = DictionaryController.createDefaultDictionarySettings(title, enabled, styles);
|
||||
const path1 = `profiles[${i}].options.dictionaries`;
|
||||
|
||||
if (profilesDictionarySettings === null || typeof profilesDictionarySettings[profileId] === 'undefined') {
|
||||
targets.push({action: 'push', path: path1, items: [defaultSettings]});
|
||||
} else {
|
||||
const {index, alias, name, ...currentSettings} = profilesDictionarySettings[profileId];
|
||||
const newAlias = alias === name ? title : alias;
|
||||
targets.push({
|
||||
action: 'splice',
|
||||
path: path1,
|
||||
start: index,
|
||||
items: [{
|
||||
...currentSettings,
|
||||
styles,
|
||||
name: title,
|
||||
alias: newAlias,
|
||||
}],
|
||||
deleteCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (sequenced && options.general.mainDictionary === '') {
|
||||
const path2 = `profiles[${i}].options.general.mainDictionary`;
|
||||
targets.push({action: 'set', path: path2, value: title});
|
||||
}
|
||||
}
|
||||
return await this._modifyGlobalSettings(targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Error[]>}
|
||||
*/
|
||||
async _clearDictionarySettings() {
|
||||
const optionsFull = await this._settingsController.getOptionsFull();
|
||||
/** @type {import('settings-modifications').Modification[]} */
|
||||
const targets = [];
|
||||
const profileCount = optionsFull.profiles.length;
|
||||
for (let i = 0; i < profileCount; ++i) {
|
||||
const path1 = `profiles[${i}].options.dictionaries`;
|
||||
targets.push({action: 'set', path: path1, value: []});
|
||||
const path2 = `profiles[${i}].options.general.mainDictionary`;
|
||||
targets.push({action: 'set', path: path2, value: ''});
|
||||
}
|
||||
return await this._modifyGlobalSettings(targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('settings-controller').PageExitPrevention}
|
||||
*/
|
||||
_preventPageExit() {
|
||||
return this._settingsController.preventPageExit();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Error[]} errors
|
||||
*/
|
||||
_showErrors(errors) {
|
||||
/** @type {Map<string, number>} */
|
||||
const uniqueErrors = new Map();
|
||||
for (const error of errors) {
|
||||
log.error(error);
|
||||
const errorString = this._errorToString(error);
|
||||
let count = uniqueErrors.get(errorString);
|
||||
if (typeof count === 'undefined') {
|
||||
count = 0;
|
||||
}
|
||||
uniqueErrors.set(errorString, count + 1);
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const [e, count] of uniqueErrors.entries()) {
|
||||
const div = document.createElement('p');
|
||||
if (count > 1) {
|
||||
div.textContent = `${e} `;
|
||||
const em = document.createElement('em');
|
||||
em.textContent = `(${count})`;
|
||||
div.appendChild(em);
|
||||
} else {
|
||||
div.textContent = `${e}`;
|
||||
}
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
|
||||
const errorContainer = /** @type {HTMLElement} */ (this._errorContainer);
|
||||
errorContainer.appendChild(fragment);
|
||||
errorContainer.hidden = false;
|
||||
}
|
||||
|
||||
/** */
|
||||
_hideErrors() {
|
||||
const errorContainer = /** @type {HTMLElement} */ (this._errorContainer);
|
||||
errorContainer.textContent = '';
|
||||
errorContainer.hidden = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns {Promise<ArrayBuffer>}
|
||||
*/
|
||||
_readFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(/** @type {ArrayBuffer} */ (reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Error} error
|
||||
* @returns {string}
|
||||
*/
|
||||
_errorToString(error) {
|
||||
const errorMessage = error.toString();
|
||||
|
||||
for (const [match, newErrorString] of this._errorToStringOverrides) {
|
||||
if (errorMessage.includes(match)) {
|
||||
return newErrorString;
|
||||
}
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
*/
|
||||
_setModifying(value) {
|
||||
this._modifying = value;
|
||||
this._setButtonsEnabled(!value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
*/
|
||||
_setButtonsEnabled(value) {
|
||||
value = !value;
|
||||
for (const node of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.dictionary-database-mutating-input'))) {
|
||||
node.disabled = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
* @returns {Promise<Error[]>}
|
||||
*/
|
||||
async _modifyGlobalSettings(targets) {
|
||||
const results = await this._settingsController.modifyGlobalSettings(targets);
|
||||
const errors = [];
|
||||
for (const {error} of results) {
|
||||
if (typeof error !== 'undefined') {
|
||||
errors.push(ExtensionError.deserialize(error));
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** */
|
||||
_triggerStorageChanged() {
|
||||
this._settingsController.application.triggerStorageChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export class ImportProgressTracker {
|
||||
/**
|
||||
* @param {import('dictionary-importer').ImportSteps} steps
|
||||
* @param {number} dictionaryCount
|
||||
*/
|
||||
constructor(steps, dictionaryCount) {
|
||||
/** @type {import('dictionary-importer').ImportSteps} */
|
||||
this._steps = steps;
|
||||
/** @type {number} */
|
||||
this._dictionaryCount = dictionaryCount;
|
||||
|
||||
/** @type {number} */
|
||||
this._stepIndex = 0;
|
||||
/** @type {number} */
|
||||
this._dictionaryIndex = 0;
|
||||
|
||||
const progressSelector = '.dictionary-import-progress';
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
this._progressBars = (document.querySelectorAll(`${progressSelector} .progress-bar`));
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
this._infoLabels = (document.querySelectorAll(`${progressSelector} .progress-info`));
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
this._statusLabels = (document.querySelectorAll(`${progressSelector} .progress-status`));
|
||||
|
||||
this.onProgress({nextStep: false, index: 0, count: 0});
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
get statusPrefix() {
|
||||
return `Importing dictionary${this._dictionaryCount > 1 ? ` (${this._dictionaryIndex} of ${this._dictionaryCount})` : ''}`;
|
||||
}
|
||||
|
||||
/** @type {import('dictionary-importer').ImportStep} */
|
||||
get currentStep() {
|
||||
return this._steps[this._stepIndex];
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get stepCount() {
|
||||
return this._steps.length;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get dictionaryCount() {
|
||||
return this._dictionaryCount;
|
||||
}
|
||||
|
||||
/** @type {import('dictionary-worker').ImportProgressCallback} */
|
||||
onProgress(data) {
|
||||
const {nextStep, index, count} = data;
|
||||
if (nextStep) {
|
||||
this._stepIndex++;
|
||||
}
|
||||
const labelText = `${this.statusPrefix} - Step ${this._stepIndex + 1} of ${this.stepCount}: ${this.currentStep.label}...`;
|
||||
for (const label of this._infoLabels) { label.textContent = labelText; }
|
||||
|
||||
const percent = count > 0 ? (index / count * 100) : 0;
|
||||
const cssString = `${percent}%`;
|
||||
const statusString = `${Math.floor(percent).toFixed(0)}%`;
|
||||
for (const progressBar of this._progressBars) { progressBar.style.width = cssString; }
|
||||
for (const label of this._statusLabels) { label.textContent = statusString; }
|
||||
|
||||
const callback = this.currentStep?.callback;
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
onNextDictionary() {
|
||||
this._dictionaryIndex += 1;
|
||||
this._stepIndex = 0;
|
||||
this.onProgress({
|
||||
nextStep: true,
|
||||
index: 0,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
389
vendor/yomitan/js/pages/settings/extension-keyboard-shortcuts-controller.js
vendored
Normal file
389
vendor/yomitan/js/pages/settings/extension-keyboard-shortcuts-controller.js
vendored
Normal file
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {isObjectNotArray} from '../../core/object-utilities.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {HotkeyUtil} from '../../input/hotkey-util.js';
|
||||
import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js';
|
||||
|
||||
export class ExtensionKeyboardShortcutController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._resetButton = querySelectorNotNull(document, '#extension-hotkey-list-reset-all');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._clearButton = querySelectorNotNull(document, '#extension-hotkey-list-clear-all');
|
||||
/** @type {HTMLElement} */
|
||||
this._listContainer = querySelectorNotNull(document, '#extension-hotkey-list');
|
||||
/** @type {HotkeyUtil} */
|
||||
this._hotkeyUtil = new HotkeyUtil();
|
||||
/** @type {?import('environment').OperatingSystem} */
|
||||
this._os = null;
|
||||
/** @type {ExtensionKeyboardShortcutHotkeyEntry[]} */
|
||||
this._entries = [];
|
||||
}
|
||||
|
||||
/** @type {HotkeyUtil} */
|
||||
get hotkeyUtil() {
|
||||
return this._hotkeyUtil;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const canResetCommands = this.canResetCommands();
|
||||
const canModifyCommands = this.canModifyCommands();
|
||||
this._resetButton.hidden = !canResetCommands;
|
||||
this._clearButton.hidden = !canModifyCommands;
|
||||
|
||||
if (canResetCommands) {
|
||||
this._resetButton.addEventListener('click', this._onResetClick.bind(this));
|
||||
}
|
||||
if (canModifyCommands) {
|
||||
this._clearButton.addEventListener('click', this._onClearClick.bind(this));
|
||||
}
|
||||
|
||||
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
|
||||
this._os = os;
|
||||
this._hotkeyUtil.os = os;
|
||||
|
||||
const commands = await this._getCommands();
|
||||
this._setupCommands(commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Promise<{key: ?string, modifiers: import('input').Modifier[]}>}
|
||||
*/
|
||||
async resetCommand(name) {
|
||||
await this._resetCommand(name);
|
||||
|
||||
/** @type {?string} */
|
||||
let key = null;
|
||||
/** @type {import('input').Modifier[]} */
|
||||
let modifiers = [];
|
||||
|
||||
const commands = await this._getCommands();
|
||||
for (const {name: name2, shortcut} of commands) {
|
||||
if (name === name2) {
|
||||
({key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {key, modifiers};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {?string} key
|
||||
* @param {import('input').Modifier[]} modifiers
|
||||
*/
|
||||
async updateCommand(name, key, modifiers) {
|
||||
// Firefox-only; uses Promise API
|
||||
const shortcut = this._hotkeyUtil.convertInputToCommand(key, modifiers);
|
||||
await browser.commands.update({name, shortcut});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canResetCommands() {
|
||||
return (
|
||||
typeof browser === 'object' && browser !== null &&
|
||||
typeof browser.commands === 'object' && browser.commands !== null &&
|
||||
typeof browser.commands.reset === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canModifyCommands() {
|
||||
return (
|
||||
typeof browser === 'object' && browser !== null &&
|
||||
typeof browser.commands === 'object' && browser.commands !== null &&
|
||||
typeof browser.commands.update === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
// Add
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onResetClick(e) {
|
||||
e.preventDefault();
|
||||
void this._resetAllCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onClearClick(e) {
|
||||
e.preventDefault();
|
||||
void this._clearAllCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<chrome.commands.Command[]>}
|
||||
*/
|
||||
_getCommands() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!(isObjectNotArray(chrome.commands) && typeof chrome.commands.getAll === 'function')) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.commands.getAll((result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.commands.Command[]} commands
|
||||
*/
|
||||
_setupCommands(commands) {
|
||||
for (const entry of this._entries) {
|
||||
entry.cleanup();
|
||||
}
|
||||
this._entries = [];
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const {name, description, shortcut} of commands) {
|
||||
if (typeof name !== 'string' || name.startsWith('_')) { continue; }
|
||||
|
||||
const {key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut);
|
||||
|
||||
const node = this._settingsController.instantiateTemplate('extension-hotkey-list-item');
|
||||
fragment.appendChild(node);
|
||||
|
||||
const entry = new ExtensionKeyboardShortcutHotkeyEntry(this, node, name, description, key, modifiers, this._os);
|
||||
entry.prepare();
|
||||
this._entries.push(entry);
|
||||
}
|
||||
|
||||
const listContainer = /** @type {HTMLElement} */ (this._listContainer);
|
||||
listContainer.textContent = '';
|
||||
listContainer.appendChild(fragment);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _resetAllCommands() {
|
||||
if (!this.canModifyCommands()) { return; }
|
||||
|
||||
let commands = await this._getCommands();
|
||||
const promises = [];
|
||||
|
||||
for (const {name} of commands) {
|
||||
if (typeof name !== 'string' || name.startsWith('_')) { continue; }
|
||||
promises.push(this._resetCommand(name));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
commands = await this._getCommands();
|
||||
this._setupCommands(commands);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _clearAllCommands() {
|
||||
if (!this.canModifyCommands()) { return; }
|
||||
|
||||
let commands = await this._getCommands();
|
||||
const promises = [];
|
||||
|
||||
for (const {name} of commands) {
|
||||
if (typeof name !== 'string' || name.startsWith('_')) { continue; }
|
||||
promises.push(this.updateCommand(name, null, []));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
commands = await this._getCommands();
|
||||
this._setupCommands(commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
async _resetCommand(name) {
|
||||
// Firefox-only; uses Promise API
|
||||
await browser.commands.reset(name);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionKeyboardShortcutHotkeyEntry {
|
||||
/**
|
||||
* @param {ExtensionKeyboardShortcutController} parent
|
||||
* @param {Element} node
|
||||
* @param {string} name
|
||||
* @param {string|undefined} description
|
||||
* @param {?string} key
|
||||
* @param {import('input').Modifier[]} modifiers
|
||||
* @param {?import('environment').OperatingSystem} os
|
||||
*/
|
||||
constructor(parent, node, name, description, key, modifiers, os) {
|
||||
/** @type {ExtensionKeyboardShortcutController} */
|
||||
this._parent = parent;
|
||||
/** @type {Element} */
|
||||
this._node = node;
|
||||
/** @type {string} */
|
||||
this._name = name;
|
||||
/** @type {string|undefined} */
|
||||
this._description = description;
|
||||
/** @type {?string} */
|
||||
this._key = key;
|
||||
/** @type {import('input').Modifier[]} */
|
||||
this._modifiers = modifiers;
|
||||
/** @type {?import('environment').OperatingSystem} */
|
||||
this._os = os;
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._input = null;
|
||||
/** @type {?KeyboardMouseInputField} */
|
||||
this._inputField = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
/** @type {HTMLElement} */
|
||||
const label = querySelectorNotNull(this._node, '.settings-item-label');
|
||||
label.textContent = this._description || this._name;
|
||||
|
||||
/** @type {HTMLButtonElement} */
|
||||
const button = querySelectorNotNull(this._node, '.extension-hotkey-list-item-button');
|
||||
/** @type {HTMLInputElement} */
|
||||
const input = querySelectorNotNull(this._node, 'input');
|
||||
|
||||
this._input = input;
|
||||
|
||||
if (this._parent.canModifyCommands()) {
|
||||
this._inputField = new KeyboardMouseInputField(input, null, this._os);
|
||||
this._inputField.prepare(this._key, this._modifiers, false, true);
|
||||
this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this));
|
||||
this._eventListeners.addEventListener(button, 'menuClose', this._onMenuClose.bind(this));
|
||||
this._eventListeners.addEventListener(input, 'blur', this._onInputFieldBlur.bind(this));
|
||||
} else {
|
||||
input.readOnly = true;
|
||||
input.value = this._parent.hotkeyUtil.getInputDisplayValue(this._key, this._modifiers);
|
||||
button.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
if (this._node.parentNode !== null) {
|
||||
this._node.parentNode.removeChild(this._node);
|
||||
}
|
||||
if (this._inputField !== null) {
|
||||
this._inputField.cleanup();
|
||||
this._inputField = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} e
|
||||
*/
|
||||
_onInputFieldChange(e) {
|
||||
const {key, modifiers} = e;
|
||||
void this._tryUpdateInput(key, modifiers, false);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onInputFieldBlur() {
|
||||
this._updateInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onMenuClose(e) {
|
||||
switch (e.detail.action) {
|
||||
case 'clearInput':
|
||||
void this._tryUpdateInput(null, [], true);
|
||||
break;
|
||||
case 'resetInput':
|
||||
void this._resetInput();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateInput() {
|
||||
/** @type {KeyboardMouseInputField} */ (this._inputField).setInput(this._key, this._modifiers);
|
||||
if (this._input !== null) {
|
||||
delete this._input.dataset.invalid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?string} key
|
||||
* @param {import('input').Modifier[]} modifiers
|
||||
* @param {boolean} updateInput
|
||||
*/
|
||||
async _tryUpdateInput(key, modifiers, updateInput) {
|
||||
let okay = (key === null ? (modifiers.length === 0) : (modifiers.length > 0));
|
||||
if (okay) {
|
||||
try {
|
||||
await this._parent.updateCommand(this._name, key, modifiers);
|
||||
} catch (e) {
|
||||
okay = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (okay) {
|
||||
this._key = key;
|
||||
this._modifiers = modifiers;
|
||||
if (this._input !== null) {
|
||||
delete this._input.dataset.invalid;
|
||||
}
|
||||
} else {
|
||||
if (this._input !== null) {
|
||||
this._input.dataset.invalid = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInput) {
|
||||
this._updateInput();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _resetInput() {
|
||||
const {key, modifiers} = await this._parent.resetCommand(this._name);
|
||||
this._key = key;
|
||||
this._modifiers = modifiers;
|
||||
this._updateInput();
|
||||
}
|
||||
}
|
||||
370
vendor/yomitan/js/pages/settings/generic-setting-controller.js
vendored
Normal file
370
vendor/yomitan/js/pages/settings/generic-setting-controller.js
vendored
Normal file
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ExtensionError} from '../../core/extension-error.js';
|
||||
import {parseJson} from '../../core/json.js';
|
||||
import {convertElementValueToNumber} from '../../dom/document-util.js';
|
||||
import {DOMDataBinder} from '../../dom/dom-data-binder.js';
|
||||
|
||||
export class GenericSettingController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {import('settings-modifications').OptionsScopeType} */
|
||||
this._defaultScope = 'profile';
|
||||
/** @type {DOMDataBinder<import('generic-setting-controller').ElementMetadata>} */
|
||||
this._dataBinder = new DOMDataBinder(
|
||||
['[data-setting]', '[data-permissions-setting]'],
|
||||
this._createElementMetadata.bind(this),
|
||||
this._compareElementMetadata.bind(this),
|
||||
this._getValues.bind(this),
|
||||
this._setValues.bind(this),
|
||||
);
|
||||
/** @type {Map<import('generic-setting-controller').TransformType, import('generic-setting-controller').TransformFunction>} */
|
||||
this._transforms = new Map(/** @type {[key: import('generic-setting-controller').TransformType, value: import('generic-setting-controller').TransformFunction][]} */ ([
|
||||
['setAttribute', this._setAttribute.bind(this)],
|
||||
['setVisibility', this._setVisibility.bind(this)],
|
||||
['splitTags', this._splitTags.bind(this)],
|
||||
['joinTags', this._joinTags.bind(this)],
|
||||
['toNumber', this._toNumber.bind(this)],
|
||||
['toBoolean', this._toBoolean.bind(this)],
|
||||
['toString', this._toString.bind(this)],
|
||||
['conditionalConvert', this._conditionalConvert.bind(this)],
|
||||
]));
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._dataBinder.observe(document.body);
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
}
|
||||
|
||||
/** */
|
||||
async refresh() {
|
||||
await this._dataBinder.refresh();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onOptionsChanged() {
|
||||
void this._dataBinder.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {import('generic-setting-controller').ElementMetadata|undefined}
|
||||
*/
|
||||
_createElementMetadata(element) {
|
||||
if (!(element instanceof HTMLElement)) { return void 0; }
|
||||
const {scope, transform: transformRaw} = element.dataset;
|
||||
const path = element.dataset.setting ?? element.dataset.permissionsSetting;
|
||||
if (typeof path !== 'string') { return void 0; }
|
||||
const scope2 = this._normalizeScope(scope);
|
||||
return {
|
||||
path,
|
||||
scope: scope2 !== null ? scope2 : this._defaultScope,
|
||||
transforms: this._getTransformDataArray(transformRaw),
|
||||
transformRaw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('generic-setting-controller').ElementMetadata} metadata1
|
||||
* @param {import('generic-setting-controller').ElementMetadata} metadata2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_compareElementMetadata(metadata1, metadata2) {
|
||||
return (
|
||||
metadata1.path === metadata2.path &&
|
||||
metadata1.scope === metadata2.scope &&
|
||||
metadata1.transformRaw === metadata2.transformRaw
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').GetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets
|
||||
* @returns {Promise<import('dom-data-binder').TaskResult[]>}
|
||||
*/
|
||||
async _getValues(targets) {
|
||||
const defaultScope = this._defaultScope;
|
||||
/** @type {import('settings-modifications').ScopedRead[]} */
|
||||
const settingsTargets = [];
|
||||
for (const {metadata: {path, scope}} of targets) {
|
||||
/** @type {import('settings-modifications').ScopedRead} */
|
||||
const target = {
|
||||
path,
|
||||
scope: typeof scope === 'string' ? scope : defaultScope,
|
||||
optionsContext: null,
|
||||
};
|
||||
settingsTargets.push(target);
|
||||
}
|
||||
return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').SetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets
|
||||
* @returns {Promise<import('dom-data-binder').TaskResult[]>}
|
||||
*/
|
||||
async _setValues(targets) {
|
||||
const defaultScope = this._defaultScope;
|
||||
/** @type {import('settings-modifications').ScopedModification[]} */
|
||||
const settingsTargets = [];
|
||||
for (const {metadata: {path, scope, transforms}, value, element} of targets) {
|
||||
const transformedValue = this._applyTransforms(value, transforms, 'pre', element);
|
||||
/** @type {import('settings-modifications').ScopedModification} */
|
||||
const target = {
|
||||
path,
|
||||
scope: typeof scope === 'string' ? scope : defaultScope,
|
||||
action: 'set',
|
||||
value: transformedValue,
|
||||
optionsContext: null,
|
||||
};
|
||||
settingsTargets.push(target);
|
||||
}
|
||||
return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').ModifyResult[]} values
|
||||
* @param {import('dom-data-binder').GetValuesDetails<import('generic-setting-controller').ElementMetadata>[]|import('dom-data-binder').SetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets
|
||||
* @returns {import('dom-data-binder').TaskResult[]}
|
||||
*/
|
||||
_transformResults(values, targets) {
|
||||
return values.map((value, i) => {
|
||||
const error = value.error;
|
||||
if (error) { return {error: ExtensionError.deserialize(error)}; }
|
||||
const {metadata: {transforms}, element} = targets[i];
|
||||
const result = this._applyTransforms(value.result, transforms, 'post', element);
|
||||
return {result};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {import('generic-setting-controller').TransformData[]} transforms
|
||||
* @param {import('generic-setting-controller').TransformStep} step
|
||||
* @param {Element} element
|
||||
* @returns {unknown}
|
||||
*/
|
||||
_applyTransforms(value, transforms, step, element) {
|
||||
for (const transform of transforms) {
|
||||
const transformStep = transform.step;
|
||||
if (typeof transformStep !== 'undefined' && transformStep !== step) { continue; }
|
||||
|
||||
const transformFunction = this._transforms.get(transform.type);
|
||||
if (typeof transformFunction === 'undefined') { continue; }
|
||||
|
||||
value = transformFunction(value, transform, element);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?Node} node
|
||||
* @param {number} ancestorDistance
|
||||
* @returns {?Node}
|
||||
*/
|
||||
_getAncestor(node, ancestorDistance) {
|
||||
if (ancestorDistance < 0) {
|
||||
return document.documentElement;
|
||||
}
|
||||
for (let i = 0; i < ancestorDistance && node !== null; ++i) {
|
||||
node = node.parentNode;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?Node} node
|
||||
* @param {number|undefined} ancestorDistance
|
||||
* @param {string|undefined} selector
|
||||
* @returns {?Node}
|
||||
*/
|
||||
_getRelativeElement(node, ancestorDistance, selector) {
|
||||
const selectorRoot = (
|
||||
typeof ancestorDistance === 'number' ?
|
||||
this._getAncestor(node, ancestorDistance) :
|
||||
document
|
||||
);
|
||||
if (selectorRoot === null) { return null; }
|
||||
|
||||
return (
|
||||
typeof selector === 'string' && (selectorRoot instanceof Element || selectorRoot instanceof Document) ?
|
||||
selectorRoot.querySelector(selector) :
|
||||
(selectorRoot === document ? document.documentElement : selectorRoot)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('generic-setting-controller').OperationData} operationData
|
||||
* @param {unknown} lhs
|
||||
* @returns {unknown}
|
||||
*/
|
||||
_evaluateSimpleOperation(operationData, lhs) {
|
||||
const {op: operation, value: rhs} = operationData;
|
||||
switch (operation) {
|
||||
case '!': return !lhs;
|
||||
case '!!': return !!lhs;
|
||||
case '===': return lhs === rhs;
|
||||
case '!==': return lhs !== rhs;
|
||||
case '>=': return /** @type {number} */ (lhs) >= /** @type {number} */ (rhs);
|
||||
case '<=': return /** @type {number} */ (lhs) <= /** @type {number} */ (rhs);
|
||||
case '>': return /** @type {number} */ (lhs) > /** @type {number} */ (rhs);
|
||||
case '<': return /** @type {number} */ (lhs) < /** @type {number} */ (rhs);
|
||||
case '&&':
|
||||
for (const operationData2 of /** @type {import('generic-setting-controller').OperationData[]} */ (rhs)) {
|
||||
const result = this._evaluateSimpleOperation(operationData2, lhs);
|
||||
if (!result) { return result; }
|
||||
}
|
||||
return true;
|
||||
case '||':
|
||||
for (const operationData2 of /** @type {import('generic-setting-controller').OperationData[]} */ (rhs)) {
|
||||
const result = this._evaluateSimpleOperation(operationData2, lhs);
|
||||
if (result) { return result; }
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} value
|
||||
* @returns {?import('settings-modifications').OptionsScopeType}
|
||||
*/
|
||||
_normalizeScope(value) {
|
||||
switch (value) {
|
||||
case 'profile':
|
||||
case 'global':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} transformRaw
|
||||
* @returns {import('generic-setting-controller').TransformData[]}
|
||||
*/
|
||||
_getTransformDataArray(transformRaw) {
|
||||
if (typeof transformRaw === 'string') {
|
||||
const transforms = parseJson(transformRaw);
|
||||
return Array.isArray(transforms) ? transforms : [transforms];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Transforms
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {import('generic-setting-controller').SetAttributeTransformData} data
|
||||
* @param {Element} element
|
||||
* @returns {unknown}
|
||||
*/
|
||||
_setAttribute(value, data, element) {
|
||||
const {ancestorDistance, selector, attribute} = data;
|
||||
const relativeElement = this._getRelativeElement(element, ancestorDistance, selector);
|
||||
if (relativeElement !== null && relativeElement instanceof Element) {
|
||||
relativeElement.setAttribute(attribute, `${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {import('generic-setting-controller').SetVisibilityTransformData} data
|
||||
* @param {Element} element
|
||||
* @returns {unknown}
|
||||
*/
|
||||
_setVisibility(value, data, element) {
|
||||
const {ancestorDistance, selector, condition} = data;
|
||||
const relativeElement = this._getRelativeElement(element, ancestorDistance, selector);
|
||||
if (relativeElement !== null && relativeElement instanceof HTMLElement) {
|
||||
relativeElement.hidden = !this._evaluateSimpleOperation(condition, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_splitTags(value) {
|
||||
return `${value}`.split(/[,; ]+/).filter((v) => !!v);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_joinTags(value) {
|
||||
return Array.isArray(value) ? value.join(' ') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {import('generic-setting-controller').ToNumberConstraintsTransformData} data
|
||||
* @returns {number}
|
||||
*/
|
||||
_toNumber(value, data) {
|
||||
/** @type {import('document-util').ToNumberConstraints} */
|
||||
const constraints = typeof data.constraints === 'object' && data.constraints !== null ? data.constraints : {};
|
||||
return typeof value === 'string' ? convertElementValueToNumber(value, constraints) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_toBoolean(value) {
|
||||
return (value === 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_toString(value) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {import('generic-setting-controller').ConditionalConvertTransformData} data
|
||||
* @returns {unknown}
|
||||
*/
|
||||
_conditionalConvert(value, data) {
|
||||
const {cases} = data;
|
||||
if (Array.isArray(cases)) {
|
||||
for (const caseData of cases) {
|
||||
if (caseData.default === true) {
|
||||
value = caseData.result;
|
||||
} else if (this._evaluateSimpleOperation(caseData, value)) {
|
||||
value = caseData.result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
332
vendor/yomitan/js/pages/settings/keyboard-mouse-input-field.js
vendored
Normal file
332
vendor/yomitan/js/pages/settings/keyboard-mouse-input-field.js
vendored
Normal file
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventDispatcher} from '../../core/event-dispatcher.js';
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {getActiveButtons, getActiveModifiers} from '../../dom/document-util.js';
|
||||
import {HotkeyUtil} from '../../input/hotkey-util.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('keyboard-mouse-input-field').Events>
|
||||
*/
|
||||
export class KeyboardMouseInputField extends EventDispatcher {
|
||||
/**
|
||||
* @param {HTMLInputElement} inputNode
|
||||
* @param {?HTMLButtonElement} mouseButton
|
||||
* @param {?import('environment').OperatingSystem} os
|
||||
* @param {?(pointerType: string) => boolean} [isPointerTypeSupported]
|
||||
*/
|
||||
constructor(inputNode, mouseButton, os, isPointerTypeSupported = null) {
|
||||
super();
|
||||
/** @type {HTMLInputElement} */
|
||||
this._inputNode = inputNode;
|
||||
/** @type {?HTMLButtonElement} */
|
||||
this._mouseButton = mouseButton;
|
||||
/** @type {?(pointerType: string) => boolean} */
|
||||
this._isPointerTypeSupported = isPointerTypeSupported;
|
||||
/** @type {HotkeyUtil} */
|
||||
this._hotkeyUtil = new HotkeyUtil(os);
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {?string} */
|
||||
this._key = null;
|
||||
/** @type {import('input').Modifier[]} */
|
||||
this._modifiers = [];
|
||||
/** @type {Set<number>} */
|
||||
this._penPointerIds = new Set();
|
||||
/** @type {boolean} */
|
||||
this._mouseModifiersSupported = false;
|
||||
/** @type {boolean} */
|
||||
this._keySupported = false;
|
||||
}
|
||||
|
||||
/** @type {import('input').Modifier[]} */
|
||||
get modifiers() {
|
||||
return this._modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?string} key
|
||||
* @param {import('input').Modifier[]} modifiers
|
||||
* @param {boolean} [mouseModifiersSupported]
|
||||
* @param {boolean} [keySupported]
|
||||
*/
|
||||
prepare(key, modifiers, mouseModifiersSupported = false, keySupported = false) {
|
||||
this.cleanup();
|
||||
|
||||
this._mouseModifiersSupported = mouseModifiersSupported;
|
||||
this._keySupported = keySupported;
|
||||
this.setInput(key, modifiers);
|
||||
/** @type {import('event-listener-collection').AddEventListenerArgs[]} */
|
||||
const events = [
|
||||
[this._inputNode, 'keydown', this._onModifierKeyDown.bind(this), false],
|
||||
[this._inputNode, 'keyup', this._onModifierKeyUp.bind(this), false],
|
||||
];
|
||||
if (mouseModifiersSupported && this._mouseButton !== null) {
|
||||
events.push(
|
||||
[this._mouseButton, 'mousedown', this._onMouseButtonMouseDown.bind(this), false],
|
||||
[this._mouseButton, 'pointerdown', this._onMouseButtonPointerDown.bind(this), false],
|
||||
[this._mouseButton, 'pointerover', this._onMouseButtonPointerOver.bind(this), false],
|
||||
[this._mouseButton, 'pointerout', this._onMouseButtonPointerOut.bind(this), false],
|
||||
[this._mouseButton, 'pointercancel', this._onMouseButtonPointerCancel.bind(this), false],
|
||||
[this._mouseButton, 'mouseup', this._onMouseButtonMouseUp.bind(this), false],
|
||||
[this._mouseButton, 'contextmenu', this._onMouseButtonContextMenu.bind(this), false],
|
||||
);
|
||||
}
|
||||
for (const args of events) {
|
||||
this._eventListeners.addEventListener(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?string} key
|
||||
* @param {import('input').Modifier[]} modifiers
|
||||
*/
|
||||
setInput(key, modifiers) {
|
||||
this._key = key;
|
||||
this._modifiers = this._sortModifiers(modifiers);
|
||||
this._updateDisplayString();
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._modifiers = [];
|
||||
this._key = null;
|
||||
this._mouseModifiersSupported = false;
|
||||
this._keySupported = false;
|
||||
this._penPointerIds.clear();
|
||||
}
|
||||
|
||||
/** */
|
||||
clearInputs() {
|
||||
this._updateModifiers([], null);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('input').Modifier[]} modifiers
|
||||
* @returns {import('input').Modifier[]}
|
||||
*/
|
||||
_sortModifiers(modifiers) {
|
||||
return this._hotkeyUtil.sortModifiers(modifiers);
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateDisplayString() {
|
||||
const displayValue = this._hotkeyUtil.getInputDisplayValue(this._key, this._modifiers);
|
||||
this._inputNode.value = displayValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
* @returns {Set<import('input').ModifierKey>}
|
||||
*/
|
||||
_getModifierKeys(e) {
|
||||
const modifiers = new Set(getActiveModifiers(e));
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
|
||||
// https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta
|
||||
// It works with mouse events on some platforms, so try to determine if metaKey is pressed.
|
||||
// This is a hack and only works when both Shift and Alt are not pressed.
|
||||
if (
|
||||
!modifiers.has('meta') &&
|
||||
e.key === 'Meta' &&
|
||||
!(
|
||||
modifiers.size === 2 &&
|
||||
modifiers.has('shift') &&
|
||||
modifiers.has('alt')
|
||||
)
|
||||
) {
|
||||
modifiers.add('meta');
|
||||
}
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} keyName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isModifierKey(keyName) {
|
||||
switch (keyName) {
|
||||
case 'AltLeft':
|
||||
case 'AltRight':
|
||||
case 'ControlLeft':
|
||||
case 'ControlRight':
|
||||
case 'MetaLeft':
|
||||
case 'MetaRight':
|
||||
case 'ShiftLeft':
|
||||
case 'ShiftRight':
|
||||
case 'OSLeft':
|
||||
case 'OSRight':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_onModifierKeyDown(e) {
|
||||
e.preventDefault();
|
||||
|
||||
/** @type {string|undefined} */
|
||||
let key = e.code;
|
||||
if (key === 'Unidentified' || key === '') { key = void 0; }
|
||||
if (this._keySupported) {
|
||||
this._updateModifiers([...this._getModifierKeys(e)], this._isModifierKey(key) ? void 0 : key);
|
||||
} else {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
case 'Backspace':
|
||||
this.clearInputs();
|
||||
break;
|
||||
default:
|
||||
this._addModifiers(this._getModifierKeys(e));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_onModifierKeyUp(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onMouseButtonMouseDown(e) {
|
||||
e.preventDefault();
|
||||
this._addModifiers(getActiveButtons(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} e
|
||||
*/
|
||||
_onMouseButtonPointerDown(e) {
|
||||
if (!e.isPrimary) { return; }
|
||||
|
||||
let {pointerType, pointerId} = e;
|
||||
// Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events.
|
||||
if (this._penPointerIds.has(pointerId)) { pointerType = 'pen'; }
|
||||
|
||||
if (
|
||||
typeof this._isPointerTypeSupported !== 'function' ||
|
||||
!this._isPointerTypeSupported(pointerType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this._addModifiers(getActiveButtons(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} e
|
||||
*/
|
||||
_onMouseButtonPointerOver(e) {
|
||||
const {pointerType, pointerId} = e;
|
||||
if (pointerType === 'pen') {
|
||||
this._penPointerIds.add(pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} e
|
||||
*/
|
||||
_onMouseButtonPointerOut(e) {
|
||||
const {pointerId} = e;
|
||||
this._penPointerIds.delete(pointerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} e
|
||||
*/
|
||||
_onMouseButtonPointerCancel(e) {
|
||||
this._onMouseButtonPointerOut(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onMouseButtonMouseUp(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onMouseButtonContextMenu(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Iterable<import('input').Modifier>} newModifiers
|
||||
* @param {?string} [newKey]
|
||||
*/
|
||||
_addModifiers(newModifiers, newKey) {
|
||||
const modifiers = new Set(this._modifiers);
|
||||
for (const modifier of newModifiers) {
|
||||
modifiers.add(modifier);
|
||||
}
|
||||
this._updateModifiers([...modifiers], newKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('input').Modifier[]} modifiers
|
||||
* @param {?string} [newKey]
|
||||
*/
|
||||
_updateModifiers(modifiers, newKey) {
|
||||
modifiers = this._sortModifiers(modifiers);
|
||||
|
||||
let changed = false;
|
||||
if (typeof newKey !== 'undefined' && this._key !== newKey) {
|
||||
this._key = newKey;
|
||||
changed = true;
|
||||
}
|
||||
if (!this._areArraysEqual(this._modifiers, modifiers)) {
|
||||
this._modifiers = modifiers;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
this._updateDisplayString();
|
||||
if (changed) {
|
||||
this.trigger('change', {modifiers: this._modifiers, key: this._key});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {T[]} array1
|
||||
* @param {T[]} array2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_areArraysEqual(array1, array2) {
|
||||
const length = array1.length;
|
||||
if (length !== array2.length) { return false; }
|
||||
|
||||
for (let i = 0; i < length; ++i) {
|
||||
if (array1[i] !== array2[i]) { return false; }
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
814
vendor/yomitan/js/pages/settings/keyboard-shortcuts-controller.js
vendored
Normal file
814
vendor/yomitan/js/pages/settings/keyboard-shortcuts-controller.js
vendored
Normal file
@@ -0,0 +1,814 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {convertElementValueToNumber, normalizeModifierKey} from '../../dom/document-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js';
|
||||
import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js';
|
||||
|
||||
export class KeyboardShortcutController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {KeyboardShortcutHotkeyEntry[]} */
|
||||
this._entries = [];
|
||||
/** @type {?import('environment').OperatingSystem} */
|
||||
this._os = null;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._addButton = querySelectorNotNull(document, '#hotkey-list-add');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._resetButton = querySelectorNotNull(document, '#hotkey-list-reset');
|
||||
/** @type {HTMLElement} */
|
||||
this._listContainer = querySelectorNotNull(document, '#hotkey-list');
|
||||
/** @type {HTMLElement} */
|
||||
this._emptyIndicator = querySelectorNotNull(document, '#hotkey-list-empty');
|
||||
/** @type {Intl.Collator} */
|
||||
this._stringComparer = new Intl.Collator('en-US'); // Invariant locale
|
||||
/** @type {HTMLElement} */
|
||||
this._scrollContainer = querySelectorNotNull(document, '#keyboard-shortcuts-modal .modal-body');
|
||||
/* eslint-disable @stylistic/no-multi-spaces */
|
||||
/** @type {Map<string, import('keyboard-shortcut-controller').ActionDetails>} */
|
||||
this._actionDetails = new Map([
|
||||
['', {scopes: new Set()}],
|
||||
['close', {scopes: new Set(['popup', 'search'])}],
|
||||
['focusSearchBox', {scopes: new Set(['search'])}],
|
||||
['nextEntry', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-move-offset', default: '1'}}],
|
||||
['previousEntry', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-move-offset', default: '1'}}],
|
||||
['lastEntry', {scopes: new Set(['popup', 'search'])}],
|
||||
['firstEntry', {scopes: new Set(['popup', 'search'])}],
|
||||
['nextEntryDifferentDictionary', {scopes: new Set(['popup', 'search'])}],
|
||||
['previousEntryDifferentDictionary', {scopes: new Set(['popup', 'search'])}],
|
||||
['historyBackward', {scopes: new Set(['popup', 'search'])}],
|
||||
['historyForward', {scopes: new Set(['popup', 'search'])}],
|
||||
['profilePrevious', {scopes: new Set(['popup', 'search', 'web'])}],
|
||||
['profileNext', {scopes: new Set(['popup', 'search', 'web'])}],
|
||||
['addNote', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-anki-card-format', default: '0'}}],
|
||||
['viewNotes', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-anki-card-format', default: '0'}}],
|
||||
['playAudio', {scopes: new Set(['popup', 'search'])}],
|
||||
['playAudioFromSource', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-audio-source', default: 'jpod101'}}],
|
||||
['copyHostSelection', {scopes: new Set(['popup'])}],
|
||||
['scanSelectedText', {scopes: new Set(['web'])}],
|
||||
['scanTextAtSelection', {scopes: new Set(['web'])}],
|
||||
['scanTextAtCaret', {scopes: new Set(['web'])}],
|
||||
['toggleOption', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-setting-path', default: ''}}],
|
||||
]);
|
||||
/* eslint-enable @stylistic/no-multi-spaces */
|
||||
}
|
||||
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
get settingsController() {
|
||||
return this._settingsController;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
|
||||
this._os = os;
|
||||
|
||||
this._addButton.addEventListener('click', this._onAddClick.bind(this));
|
||||
this._resetButton.addEventListener('click', this._onResetClick.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
|
||||
await this._updateOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').InputsHotkeyOptions} terminationCharacterEntry
|
||||
*/
|
||||
async addEntry(terminationCharacterEntry) {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {inputs: {hotkeys}} = options;
|
||||
|
||||
await this._settingsController.modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'inputs.hotkeys',
|
||||
start: hotkeys.length,
|
||||
deleteCount: 0,
|
||||
items: [terminationCharacterEntry],
|
||||
}]);
|
||||
|
||||
await this._updateOptions();
|
||||
const scrollContainer = /** @type {HTMLElement} */ (this._scrollContainer);
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async deleteEntry(index) {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {inputs: {hotkeys}} = options;
|
||||
|
||||
if (index < 0 || index >= hotkeys.length) { return false; }
|
||||
|
||||
await this._settingsController.modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'inputs.hotkeys',
|
||||
start: index,
|
||||
deleteCount: 1,
|
||||
items: [],
|
||||
}]);
|
||||
|
||||
await this._updateOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async modifyProfileSettings(targets) {
|
||||
return await this._settingsController.modifyProfileSettings(targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('settings').InputsHotkeyOptions[]>}
|
||||
*/
|
||||
async getDefaultHotkeys() {
|
||||
const defaultOptions = await this._settingsController.getDefaultOptions();
|
||||
return defaultOptions.profiles[0].options.inputs.hotkeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} action
|
||||
* @returns {import('keyboard-shortcut-controller').ActionDetails|undefined}
|
||||
*/
|
||||
getActionDetails(action) {
|
||||
return this._actionDetails.get(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async getAnkiCardFormats() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {anki} = options;
|
||||
return anki.cardFormats.map((cardFormat) => cardFormat.name);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
async _onOptionsChanged({options}) {
|
||||
for (const entry of this._entries) {
|
||||
entry.cleanup();
|
||||
}
|
||||
|
||||
this._entries = [];
|
||||
const os = /** @type {import('environment').OperatingSystem} */ (this._os);
|
||||
const {inputs: {hotkeys}} = options;
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0, ii = hotkeys.length; i < ii; ++i) {
|
||||
const hotkeyEntry = hotkeys[i];
|
||||
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('hotkey-list-item'));
|
||||
fragment.appendChild(node);
|
||||
const entry = new KeyboardShortcutHotkeyEntry(this, hotkeyEntry, i, node, os, this._stringComparer);
|
||||
this._entries.push(entry);
|
||||
await entry.prepare();
|
||||
}
|
||||
|
||||
const listContainer = /** @type {HTMLElement} */ (this._listContainer);
|
||||
listContainer.appendChild(fragment);
|
||||
listContainer.hidden = (hotkeys.length === 0);
|
||||
/** @type {HTMLElement} */ (this._emptyIndicator).hidden = (hotkeys.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onAddClick(e) {
|
||||
e.preventDefault();
|
||||
void this._addNewEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onResetClick(e) {
|
||||
e.preventDefault();
|
||||
void this._reset();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _addNewEntry() {
|
||||
/** @type {import('settings').InputsHotkeyOptions} */
|
||||
const newEntry = {
|
||||
action: '',
|
||||
argument: '',
|
||||
key: null,
|
||||
modifiers: [],
|
||||
scopes: ['popup', 'search'],
|
||||
enabled: true,
|
||||
};
|
||||
await this.addEntry(newEntry);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateOptions() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
await this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/** */
|
||||
async _reset() {
|
||||
const value = await this.getDefaultHotkeys();
|
||||
await this._settingsController.setProfileSetting('inputs.hotkeys', value);
|
||||
await this._updateOptions();
|
||||
}
|
||||
}
|
||||
|
||||
class KeyboardShortcutHotkeyEntry {
|
||||
/**
|
||||
* @param {KeyboardShortcutController} parent
|
||||
* @param {import('settings').InputsHotkeyOptions} data
|
||||
* @param {number} index
|
||||
* @param {HTMLElement} node
|
||||
* @param {import('environment').OperatingSystem} os
|
||||
* @param {Intl.Collator} stringComparer
|
||||
*/
|
||||
constructor(parent, data, index, node, os, stringComparer) {
|
||||
/** @type {KeyboardShortcutController} */
|
||||
this._parent = parent;
|
||||
/** @type {import('settings').InputsHotkeyOptions} */
|
||||
this._data = data;
|
||||
/** @type {number} */
|
||||
this._index = index;
|
||||
/** @type {HTMLElement} */
|
||||
this._node = node;
|
||||
/** @type {import('environment').OperatingSystem} */
|
||||
this._os = os;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {?KeyboardMouseInputField} */
|
||||
this._inputField = null;
|
||||
/** @type {?HTMLSelectElement} */
|
||||
this._actionSelect = null;
|
||||
/** @type {string} */
|
||||
this._basePath = `inputs.hotkeys[${this._index}]`;
|
||||
/** @type {Intl.Collator} */
|
||||
this._stringComparer = stringComparer;
|
||||
/** @type {?HTMLButtonElement} */
|
||||
this._enabledButton = null;
|
||||
/** @type {?import('../../dom/popup-menu.js').PopupMenu} */
|
||||
this._scopeMenu = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._scopeMenuEventListeners = new EventListenerCollection();
|
||||
/** @type {?HTMLElement} */
|
||||
this._argumentContainer = null;
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._argumentInput = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._argumentEventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const node = this._node;
|
||||
|
||||
/** @type {HTMLButtonElement} */
|
||||
const menuButton = querySelectorNotNull(node, '.hotkey-list-item-button');
|
||||
/** @type {HTMLInputElement} */
|
||||
const input = querySelectorNotNull(node, '.hotkey-list-item-input');
|
||||
/** @type {HTMLSelectElement} */
|
||||
const action = querySelectorNotNull(node, '.hotkey-list-item-action');
|
||||
/** @type {HTMLInputElement} */
|
||||
const enabledToggle = querySelectorNotNull(node, '.hotkey-list-item-enabled');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const scopesButton = querySelectorNotNull(node, '.hotkey-list-item-scopes-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const enabledButton = querySelectorNotNull(node, '.hotkey-list-item-enabled-button');
|
||||
|
||||
this._actionSelect = action;
|
||||
this._enabledButton = enabledButton;
|
||||
this._argumentContainer = node.querySelector('.hotkey-list-item-action-argument-container');
|
||||
|
||||
this._inputField = new KeyboardMouseInputField(input, null, this._os);
|
||||
this._inputField.prepare(this._data.key, this._data.modifiers, false, true);
|
||||
|
||||
action.value = this._data.action;
|
||||
|
||||
enabledToggle.checked = this._data.enabled;
|
||||
enabledToggle.dataset.setting = `${this._basePath}.enabled`;
|
||||
|
||||
this._updateScopesButton();
|
||||
await this._updateActionArgument();
|
||||
|
||||
this._eventListeners.addEventListener(scopesButton, 'menuOpen', this._onScopesMenuOpen.bind(this));
|
||||
this._eventListeners.addEventListener(scopesButton, 'menuClose', this._onScopesMenuClose.bind(this));
|
||||
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
|
||||
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._actionSelect, 'change', this._onActionSelectChange.bind(this), false);
|
||||
this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this));
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
/** @type {KeyboardMouseInputField} */ (this._inputField).cleanup();
|
||||
this._clearScopeMenu();
|
||||
this._clearArgumentEventListeners();
|
||||
if (this._node.parentNode !== null) {
|
||||
this._node.parentNode.removeChild(this._node);
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuOpenEvent} e
|
||||
*/
|
||||
_onMenuOpen(e) {
|
||||
const {action} = this._data;
|
||||
|
||||
const {menu} = e.detail;
|
||||
/** @type {HTMLElement} */
|
||||
const resetArgument = querySelectorNotNull(menu.bodyNode, '.popup-menu-item[data-menu-action="resetArgument"]');
|
||||
|
||||
const details = this._parent.getActionDetails(action);
|
||||
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
|
||||
|
||||
resetArgument.hidden = (typeof argumentDetails === 'undefined');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onMenuClose(e) {
|
||||
switch (e.detail.action) {
|
||||
case 'delete':
|
||||
void this._delete();
|
||||
break;
|
||||
case 'clearInputs':
|
||||
/** @type {KeyboardMouseInputField} */ (this._inputField).clearInputs();
|
||||
break;
|
||||
case 'resetInput':
|
||||
void this._resetInput();
|
||||
break;
|
||||
case 'resetArgument':
|
||||
void this._resetArgument();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuOpenEvent} e
|
||||
*/
|
||||
_onScopesMenuOpen(e) {
|
||||
const {menu} = e.detail;
|
||||
const validScopes = this._getValidScopesForAction(this._data.action);
|
||||
if (validScopes === null || validScopes.size === 0) {
|
||||
menu.close();
|
||||
return;
|
||||
}
|
||||
this._scopeMenu = menu;
|
||||
this._updateScopeMenuItems(menu);
|
||||
this._updateDisplay(menu.containerNode); // Fix a animation issue due to changing checkbox values
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onScopesMenuClose(e) {
|
||||
const {menu, action} = e.detail;
|
||||
if (action === 'toggleScope') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (this._scopeMenu === menu) {
|
||||
this._clearScopeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} details
|
||||
*/
|
||||
_onInputFieldChange({key, modifiers}) {
|
||||
/** @type {import('input').ModifierKey[]} */
|
||||
const modifiers2 = [];
|
||||
for (const modifier of modifiers) {
|
||||
const modifier2 = normalizeModifierKey(modifier);
|
||||
if (modifier2 === null) { continue; }
|
||||
modifiers2.push(modifier2);
|
||||
}
|
||||
void this._setKeyAndModifiers(key, modifiers2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onScopeCheckboxChange(e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const scope = this._normalizeScope(node.dataset.scope);
|
||||
if (scope === null) { return; }
|
||||
void this._setScopeEnabled(scope, node.checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onActionSelectChange(e) {
|
||||
const node = /** @type {HTMLSelectElement} */ (e.currentTarget);
|
||||
const value = node.value;
|
||||
void this._setAction(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onArgumentValueChange(template, e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
let value = this._getArgumentInputValue(node);
|
||||
switch (template) {
|
||||
case 'hotkey-argument-move-offset':
|
||||
value = `${convertElementValueToNumber(value, node)}`;
|
||||
break;
|
||||
}
|
||||
void this._setArgument(value);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _delete() {
|
||||
void this._parent.deleteEntry(this._index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?string} key
|
||||
* @param {import('input').ModifierKey[]} modifiers
|
||||
*/
|
||||
async _setKeyAndModifiers(key, modifiers) {
|
||||
this._data.key = key;
|
||||
this._data.modifiers = modifiers;
|
||||
await this._modifyProfileSettings([
|
||||
{
|
||||
action: 'set',
|
||||
path: `${this._basePath}.key`,
|
||||
value: key,
|
||||
},
|
||||
{
|
||||
action: 'set',
|
||||
path: `${this._basePath}.modifiers`,
|
||||
value: modifiers,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').InputsHotkeyScope} scope
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
async _setScopeEnabled(scope, enabled) {
|
||||
const scopes = this._data.scopes;
|
||||
const index = scopes.indexOf(scope);
|
||||
if ((index >= 0) === enabled) { return; }
|
||||
|
||||
if (enabled) {
|
||||
scopes.push(scope);
|
||||
const stringComparer = this._stringComparer;
|
||||
scopes.sort((scope1, scope2) => stringComparer.compare(scope1, scope2));
|
||||
} else {
|
||||
scopes.splice(index, 1);
|
||||
}
|
||||
|
||||
await this._modifyProfileSettings([{
|
||||
action: 'set',
|
||||
path: `${this._basePath}.scopes`,
|
||||
value: scopes,
|
||||
}]);
|
||||
|
||||
this._updateScopesButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async _modifyProfileSettings(targets) {
|
||||
return await this._parent.settingsController.modifyProfileSettings(targets);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _resetInput() {
|
||||
const defaultHotkeys = await this._parent.getDefaultHotkeys();
|
||||
const defaultValue = this._getDefaultKeyAndModifiers(defaultHotkeys, this._data.action);
|
||||
if (defaultValue === null) { return; }
|
||||
|
||||
const {key, modifiers} = defaultValue;
|
||||
await this._setKeyAndModifiers(key, modifiers);
|
||||
/** @type {KeyboardMouseInputField} */ (this._inputField).setInput(key, modifiers);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _resetArgument() {
|
||||
const {action} = this._data;
|
||||
const details = this._parent.getActionDetails(action);
|
||||
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
|
||||
let argumentDefault = typeof argumentDetails !== 'undefined' ? argumentDetails.default : void 0;
|
||||
if (typeof argumentDefault !== 'string') { argumentDefault = ''; }
|
||||
await this._setArgument(argumentDefault);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').InputsHotkeyOptions[]} defaultHotkeys
|
||||
* @param {string} action
|
||||
* @returns {?{modifiers: import('settings').InputsHotkeyModifier[], key: ?string}}
|
||||
*/
|
||||
_getDefaultKeyAndModifiers(defaultHotkeys, action) {
|
||||
for (const {action: action2, key, modifiers} of defaultHotkeys) {
|
||||
if (action2 !== action) { continue; }
|
||||
return {modifiers, key};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
async _setAction(value) {
|
||||
const validScopesOld = this._getValidScopesForAction(this._data.action);
|
||||
|
||||
const scopes = this._data.scopes;
|
||||
|
||||
let details = this._parent.getActionDetails(value);
|
||||
if (typeof details === 'undefined') { details = {scopes: new Set()}; }
|
||||
|
||||
const validScopes = details.scopes;
|
||||
|
||||
const {argument: argumentDetails} = details;
|
||||
let defaultArgument = typeof argumentDetails !== 'undefined' ? argumentDetails.default : '';
|
||||
if (typeof defaultArgument !== 'string') { defaultArgument = ''; }
|
||||
|
||||
this._data.action = value;
|
||||
this._data.argument = defaultArgument;
|
||||
|
||||
let scopesChanged = false;
|
||||
if ((validScopesOld !== null ? validScopesOld.size : 0) === scopes.length) {
|
||||
scopes.length = 0;
|
||||
scopesChanged = true;
|
||||
} else {
|
||||
for (let i = 0, ii = scopes.length; i < ii; ++i) {
|
||||
if (!validScopes.has(scopes[i])) {
|
||||
scopes.splice(i, 1);
|
||||
--i;
|
||||
--ii;
|
||||
scopesChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scopesChanged && scopes.length === 0) {
|
||||
scopes.push(...validScopes);
|
||||
}
|
||||
|
||||
await this._modifyProfileSettings([
|
||||
{
|
||||
action: 'set',
|
||||
path: `${this._basePath}.action`,
|
||||
value: this._data.action,
|
||||
},
|
||||
{
|
||||
action: 'set',
|
||||
path: `${this._basePath}.argument`,
|
||||
value: this._data.argument,
|
||||
},
|
||||
{
|
||||
action: 'set',
|
||||
path: `${this._basePath}.scopes`,
|
||||
value: this._data.scopes,
|
||||
},
|
||||
]);
|
||||
|
||||
this._updateScopesButton();
|
||||
this._updateScopesMenu();
|
||||
await this._updateActionArgument();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
async _setArgument(value) {
|
||||
this._data.argument = value;
|
||||
|
||||
const node = this._argumentInput;
|
||||
if (node !== null && this._getArgumentInputValue(node) !== value) {
|
||||
this._setArgumentInputValue(node, value);
|
||||
}
|
||||
|
||||
void this._updateArgumentInputValidity();
|
||||
|
||||
await this._modifyProfileSettings([{
|
||||
action: 'set',
|
||||
path: `${this._basePath}.argument`,
|
||||
value,
|
||||
}]);
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateScopesMenu() {
|
||||
if (this._scopeMenu === null) { return; }
|
||||
this._updateScopeMenuItems(this._scopeMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} action
|
||||
* @returns {?Set<import('settings').InputsHotkeyScope>}
|
||||
*/
|
||||
_getValidScopesForAction(action) {
|
||||
const details = this._parent.getActionDetails(action);
|
||||
return typeof details !== 'undefined' ? details.scopes : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../dom/popup-menu.js').PopupMenu} menu
|
||||
*/
|
||||
_updateScopeMenuItems(menu) {
|
||||
this._scopeMenuEventListeners.removeAllEventListeners();
|
||||
|
||||
const scopes = this._data.scopes;
|
||||
const validScopes = this._getValidScopesForAction(this._data.action);
|
||||
|
||||
const bodyNode = menu.bodyNode;
|
||||
const menuItems = /** @type {NodeListOf<HTMLElement>} */ (bodyNode.querySelectorAll('.popup-menu-item'));
|
||||
for (const menuItem of menuItems) {
|
||||
if (menuItem.dataset.menuAction !== 'toggleScope') { continue; }
|
||||
|
||||
const scope = this._normalizeScope(menuItem.dataset.scope);
|
||||
if (scope === null) { continue; }
|
||||
menuItem.hidden = !(validScopes === null || validScopes.has(scope));
|
||||
|
||||
/** @type {HTMLInputElement} */
|
||||
const checkbox = querySelectorNotNull(menuItem, '.hotkey-scope-checkbox');
|
||||
if (checkbox !== null) {
|
||||
checkbox.checked = scopes.includes(scope);
|
||||
this._scopeMenuEventListeners.addEventListener(checkbox, 'change', this._onScopeCheckboxChange.bind(this), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_clearScopeMenu() {
|
||||
this._scopeMenuEventListeners.removeAllEventListeners();
|
||||
this._scopeMenu = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateScopesButton() {
|
||||
const {scopes} = this._data;
|
||||
if (this._enabledButton !== null) {
|
||||
this._enabledButton.dataset.scopeCount = `${scopes.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
*/
|
||||
_updateDisplay(node) {
|
||||
const {style} = node;
|
||||
const {display} = style;
|
||||
style.display = 'none';
|
||||
getComputedStyle(node).getPropertyValue('display');
|
||||
style.display = display;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateActionArgument() {
|
||||
this._clearArgumentEventListeners();
|
||||
|
||||
const {action, argument} = this._data;
|
||||
const details = this._parent.getActionDetails(action);
|
||||
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
|
||||
|
||||
if (this._argumentContainer !== null) {
|
||||
this._argumentContainer.textContent = '';
|
||||
}
|
||||
if (typeof argumentDetails === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const {template} = argumentDetails;
|
||||
const node = this._parent.settingsController.instantiateTemplate(template);
|
||||
const inputSelector = '.hotkey-argument-input';
|
||||
const inputNode = /** @type {HTMLInputElement} */ (node.matches(inputSelector) ? node : node.querySelector(inputSelector));
|
||||
if (inputNode !== null) {
|
||||
this._setArgumentInputValue(inputNode, argument);
|
||||
this._argumentInput = inputNode;
|
||||
void this._updateArgumentInputValidity();
|
||||
this._argumentEventListeners.addEventListener(inputNode, 'change', this._onArgumentValueChange.bind(this, template), false);
|
||||
}
|
||||
if (template === 'hotkey-argument-anki-card-format') {
|
||||
const ankiCardFormats = await this._parent.getAnkiCardFormats();
|
||||
const selectNode = /** @type {HTMLSelectElement} */ (node.querySelector('.anki-card-format-select'));
|
||||
for (const [index, format] of ankiCardFormats.entries()) {
|
||||
const option = document.createElement('option');
|
||||
option.value = `${index}`;
|
||||
option.textContent = format;
|
||||
selectNode.appendChild(option);
|
||||
}
|
||||
selectNode.value = argument;
|
||||
}
|
||||
if (this._argumentContainer !== null) {
|
||||
this._argumentContainer.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_clearArgumentEventListeners() {
|
||||
this._argumentEventListeners.removeAllEventListeners();
|
||||
this._argumentInput = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLInputElement} node
|
||||
* @returns {string}
|
||||
*/
|
||||
_getArgumentInputValue(node) {
|
||||
return node.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLInputElement} node
|
||||
* @param {string} value
|
||||
*/
|
||||
_setArgumentInputValue(node, value) {
|
||||
node.value = value;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateArgumentInputValidity() {
|
||||
if (this._argumentInput === null) { return; }
|
||||
|
||||
let okay = true;
|
||||
const {action, argument} = this._data;
|
||||
const details = this._parent.getActionDetails(action);
|
||||
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
|
||||
|
||||
if (typeof argumentDetails !== 'undefined') {
|
||||
const {template} = argumentDetails;
|
||||
switch (template) {
|
||||
case 'hotkey-argument-setting-path':
|
||||
okay = await this._isHotkeyArgumentSettingPathValid(argument);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this._argumentInput.dataset.invalid = `${!okay}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _isHotkeyArgumentSettingPathValid(path) {
|
||||
if (path.length === 0) { return true; }
|
||||
|
||||
const options = await this._parent.settingsController.getOptions();
|
||||
const accessor = new ObjectPropertyAccessor(options);
|
||||
const pathArray = ObjectPropertyAccessor.getPathArray(path);
|
||||
try {
|
||||
const value = accessor.get(pathArray, pathArray.length);
|
||||
if (typeof value === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} value
|
||||
* @returns {?import('settings').InputsHotkeyScope}
|
||||
*/
|
||||
_normalizeScope(value) {
|
||||
switch (value) {
|
||||
case 'popup':
|
||||
case 'search':
|
||||
case 'web':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
vendor/yomitan/js/pages/settings/languages-controller.js
vendored
Executable file
49
vendor/yomitan/js/pages/settings/languages-controller.js
vendored
Executable file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class LanguagesController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const languages = await this._settingsController.application.api.getLanguageSummaries();
|
||||
languages.sort((a, b) => a.name.localeCompare(b.name, 'en'));
|
||||
this._fillSelect(languages);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('language').LanguageSummary[]} languages
|
||||
*/
|
||||
_fillSelect(languages) {
|
||||
const selectElement = querySelectorNotNull(document, '#language-select');
|
||||
for (const {iso, name} of languages) {
|
||||
const option = document.createElement('option');
|
||||
option.value = iso;
|
||||
option.text = `${name} (${iso})`;
|
||||
selectElement.appendChild(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
vendor/yomitan/js/pages/settings/mecab-controller.js
vendored
Normal file
82
vendor/yomitan/js/pages/settings/mecab-controller.js
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class MecabController {
|
||||
/**
|
||||
* @param {import('../../comm/api.js').API} api
|
||||
*/
|
||||
constructor(api) {
|
||||
/** @type {import('../../comm/api.js').API} */
|
||||
this._api = api;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._testButton = querySelectorNotNull(document, '#test-mecab-button');
|
||||
/** @type {HTMLElement} */
|
||||
this._resultsContainer = querySelectorNotNull(document, '#test-mecab-results');
|
||||
/** @type {boolean} */
|
||||
this._testActive = false;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._testButton.addEventListener('click', this._onTestButtonClick.bind(this), false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onTestButtonClick(e) {
|
||||
e.preventDefault();
|
||||
void this._testMecab();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _testMecab() {
|
||||
if (this._testActive) { return; }
|
||||
|
||||
try {
|
||||
this._testActive = true;
|
||||
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
|
||||
/** @type {HTMLButtonElement} */ (this._testButton).disabled = true;
|
||||
resultsContainer.textContent = '';
|
||||
resultsContainer.hidden = true;
|
||||
await this._api.testMecab();
|
||||
this._setStatus('Connection was successful', false);
|
||||
} catch (e) {
|
||||
this._setStatus(toError(e).message, true);
|
||||
} finally {
|
||||
this._testActive = false;
|
||||
/** @type {HTMLButtonElement} */ (this._testButton).disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {boolean} isError
|
||||
*/
|
||||
_setStatus(message, isError) {
|
||||
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
|
||||
resultsContainer.textContent = message;
|
||||
resultsContainer.hidden = false;
|
||||
resultsContainer.classList.toggle('danger-text', isError);
|
||||
}
|
||||
}
|
||||
85
vendor/yomitan/js/pages/settings/modal-controller.js
vendored
Normal file
85
vendor/yomitan/js/pages/settings/modal-controller.js
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HtmlTemplateCollection} from '../../dom/html-template-collection.js';
|
||||
import {Modal} from './modal.js';
|
||||
|
||||
export class ModalController {
|
||||
/**
|
||||
* @param {string[]} templateNames
|
||||
*/
|
||||
constructor(templateNames) {
|
||||
/** @type {Modal[]} */
|
||||
this._modals = [];
|
||||
/** @type {Map<string|Element, Modal>} */
|
||||
this._modalMap = new Map();
|
||||
/** @type {HtmlTemplateCollection} */
|
||||
this._templates = new HtmlTemplateCollection();
|
||||
/** @type {string[]} */
|
||||
this._templateNames = templateNames;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
if (this._templateNames.length > 0) {
|
||||
await this._templates.loadFromFiles(['/templates-modals.html']);
|
||||
for (const name of this._templateNames) {
|
||||
const template = this._templates.getTemplateContent(name);
|
||||
document.body.appendChild(template);
|
||||
}
|
||||
}
|
||||
|
||||
const idSuffix = '-modal';
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.modal'))) {
|
||||
let {id} = node;
|
||||
if (typeof id !== 'string') { continue; }
|
||||
|
||||
if (id.endsWith(idSuffix)) {
|
||||
id = id.substring(0, id.length - idSuffix.length);
|
||||
}
|
||||
|
||||
const modal = new Modal(node);
|
||||
modal.prepare();
|
||||
this._modalMap.set(id, modal);
|
||||
this._modalMap.set(node, modal);
|
||||
this._modals.push(modal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|Element} nameOrNode
|
||||
* @returns {?Modal}
|
||||
*/
|
||||
getModal(nameOrNode) {
|
||||
const modal = this._modalMap.get(nameOrNode);
|
||||
return (typeof modal !== 'undefined' ? modal : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {?Modal}
|
||||
*/
|
||||
getTopVisibleModal() {
|
||||
for (let i = this._modals.length - 1; i >= 0; --i) {
|
||||
const modal = this._modals[i];
|
||||
if (modal.isVisible()) {
|
||||
return modal;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
101
vendor/yomitan/js/pages/settings/modal.js
vendored
Normal file
101
vendor/yomitan/js/pages/settings/modal.js
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {PanelElement} from '../../dom/panel-element.js';
|
||||
|
||||
export class Modal extends PanelElement {
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
*/
|
||||
constructor(node) {
|
||||
super(node, 375); // Milliseconds; includes buffer
|
||||
/** @type {?Element} */
|
||||
this._contentNode = null;
|
||||
/** @type {boolean} */
|
||||
this._canCloseOnClick = false;
|
||||
/** @type {boolean} */
|
||||
this.forceInteract = node.classList.contains('force-interact');
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
const node = this.node;
|
||||
this._contentNode = node.querySelector('.modal-content');
|
||||
/** @type {?HTMLElement} */
|
||||
let dimmerNode = node.querySelector('.modal-content-dimmer');
|
||||
if (dimmerNode === null) { dimmerNode = node; }
|
||||
dimmerNode.addEventListener('mousedown', this._onModalContainerMouseDown.bind(this), false);
|
||||
dimmerNode.addEventListener('mouseup', this._onModalContainerMouseUp.bind(this), false);
|
||||
dimmerNode.addEventListener('click', this._onModalContainerClick.bind(this), false);
|
||||
|
||||
for (const actionNode of /** @type {NodeListOf<HTMLElement>} */ (node.querySelectorAll('[data-modal-action]'))) {
|
||||
actionNode.addEventListener('click', this._onActionNodeClick.bind(this), false);
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onModalContainerMouseDown(e) {
|
||||
this._canCloseOnClick = (e.currentTarget === e.target) && !this.forceInteract;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onModalContainerMouseUp(e) {
|
||||
if (!this._canCloseOnClick) { return; }
|
||||
this._canCloseOnClick = (e.currentTarget === e.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onModalContainerClick(e) {
|
||||
if (!this._canCloseOnClick) { return; }
|
||||
this._canCloseOnClick = false;
|
||||
if (e.currentTarget !== e.target) { return; }
|
||||
this.setVisible(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onActionNodeClick(e) {
|
||||
const element = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const {modalAction} = element.dataset;
|
||||
switch (modalAction) {
|
||||
case 'expand':
|
||||
this._setExpanded(true);
|
||||
break;
|
||||
case 'collapse':
|
||||
this._setExpanded(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} expanded
|
||||
*/
|
||||
_setExpanded(expanded) {
|
||||
if (this._contentNode === null) { return; }
|
||||
this._contentNode.classList.toggle('modal-content-full', expanded);
|
||||
}
|
||||
}
|
||||
96
vendor/yomitan/js/pages/settings/nested-popups-controller.js
vendored
Normal file
96
vendor/yomitan/js/pages/settings/nested-popups-controller.js
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {convertElementValueToNumber} from '../../dom/document-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class NestedPopupsController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {number} */
|
||||
this._popupNestingMaxDepth = 0;
|
||||
/** @type {HTMLInputElement} */
|
||||
this._nestedPopupsEnabled = querySelectorNotNull(document, '#nested-popups-enabled');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._nestedPopupsCount = querySelectorNotNull(document, '#nested-popups-count');
|
||||
/** @type {HTMLElement} */
|
||||
this._nestedPopupsEnabledMoreOptions = querySelectorNotNull(document, '#nested-popups-enabled-more-options');
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
|
||||
this._nestedPopupsEnabled.addEventListener('change', this._onNestedPopupsEnabledChange.bind(this), false);
|
||||
this._nestedPopupsCount.addEventListener('change', this._onNestedPopupsCountChange.bind(this), false);
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
this._updatePopupNestingMaxDepth(options.scanning.popupNestingMaxDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onNestedPopupsEnabledChange(e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const value = node.checked;
|
||||
if (value && this._popupNestingMaxDepth > 0) { return; }
|
||||
void this._setPopupNestingMaxDepth(value ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onNestedPopupsCountChange(e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const value = Math.max(1, convertElementValueToNumber(node.value, node));
|
||||
void this._setPopupNestingMaxDepth(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*/
|
||||
_updatePopupNestingMaxDepth(value) {
|
||||
const enabled = (value > 0);
|
||||
this._popupNestingMaxDepth = value;
|
||||
/** @type {HTMLInputElement} */ (this._nestedPopupsEnabled).checked = enabled;
|
||||
/** @type {HTMLInputElement} */ (this._nestedPopupsCount).value = `${value}`;
|
||||
/** @type {HTMLElement} */ (this._nestedPopupsEnabledMoreOptions).hidden = !enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*/
|
||||
async _setPopupNestingMaxDepth(value) {
|
||||
this._updatePopupNestingMaxDepth(value);
|
||||
await this._settingsController.setProfileSetting('scanning.popupNestingMaxDepth', value);
|
||||
}
|
||||
}
|
||||
166
vendor/yomitan/js/pages/settings/permissions-origin-controller.js
vendored
Normal file
166
vendor/yomitan/js/pages/settings/permissions-origin-controller.js
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {getAllPermissions, setPermissionsGranted} from '../../data/permissions-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class PermissionsOriginController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {HTMLElement} */
|
||||
this._originContainer = querySelectorNotNull(document, '#permissions-origin-list');
|
||||
/** @type {HTMLElement} */
|
||||
this._originEmpty = querySelectorNotNull(document, '#permissions-origin-list-empty');
|
||||
/** @type {?NodeListOf<HTMLInputElement>} */
|
||||
this._originToggleNodes = null;
|
||||
/** @type {HTMLInputElement} */
|
||||
this._addOriginInput = querySelectorNotNull(document, '#permissions-origin-new-input');
|
||||
/** @type {HTMLElement} */
|
||||
this._errorContainer = querySelectorNotNull(document, '#permissions-origin-list-error');
|
||||
/** @type {ChildNode[]} */
|
||||
this._originContainerChildren = [];
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._originToggleNodes = /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.permissions-origin-toggle'));
|
||||
/** @type {HTMLButtonElement} */
|
||||
const addButton = querySelectorNotNull(document, '#permissions-origin-add');
|
||||
|
||||
for (const node of this._originToggleNodes) {
|
||||
node.addEventListener('change', this._onOriginToggleChange.bind(this), false);
|
||||
}
|
||||
addButton.addEventListener('click', this._onAddButtonClick.bind(this), false);
|
||||
|
||||
this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this));
|
||||
await this._updatePermissions();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'permissionsChanged'>} details
|
||||
*/
|
||||
_onPermissionsChanged({permissions}) {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
for (const node of this._originContainerChildren) {
|
||||
if (node.parentNode === null) { continue; }
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
this._originContainerChildren = [];
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const originsSet = new Set(permissions.origins);
|
||||
for (const node of /** @type {NodeListOf<HTMLInputElement>} */ (this._originToggleNodes)) {
|
||||
const {origin} = node.dataset;
|
||||
node.checked = typeof origin === 'string' && originsSet.has(origin);
|
||||
}
|
||||
|
||||
let any = false;
|
||||
const excludeOrigins = new Set([
|
||||
'<all_urls>',
|
||||
]);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const origin of originsSet) {
|
||||
if (excludeOrigins.has(origin)) { continue; }
|
||||
const node = this._settingsController.instantiateTemplateFragment('permissions-origin');
|
||||
/** @type {HTMLInputElement} */
|
||||
const input = querySelectorNotNull(node, '.permissions-origin-input');
|
||||
/** @type {HTMLElement} */
|
||||
const menuButton = querySelectorNotNull(node, '.permissions-origin-button');
|
||||
input.value = origin;
|
||||
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onOriginMenuClose.bind(this, origin), false);
|
||||
this._originContainerChildren.push(...node.childNodes);
|
||||
fragment.appendChild(node);
|
||||
any = true;
|
||||
}
|
||||
const container = /** @type {HTMLElement} */ (this._originContainer);
|
||||
container.insertBefore(fragment, container.firstChild);
|
||||
/** @type {HTMLElement} */ (this._originEmpty).hidden = any;
|
||||
|
||||
/** @type {HTMLElement} */ (this._errorContainer).hidden = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onOriginToggleChange(e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const value = node.checked;
|
||||
node.checked = !value;
|
||||
|
||||
const {origin} = node.dataset;
|
||||
if (typeof origin !== 'string') { return; }
|
||||
void this._setOriginPermissionEnabled(origin, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} origin
|
||||
*/
|
||||
_onOriginMenuClose(origin) {
|
||||
void this._setOriginPermissionEnabled(origin, false);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onAddButtonClick() {
|
||||
void this._addOrigin();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _addOrigin() {
|
||||
const input = /** @type {HTMLInputElement} */ (this._addOriginInput);
|
||||
const origin = input.value;
|
||||
const added = await this._setOriginPermissionEnabled(origin, true);
|
||||
if (added) {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updatePermissions() {
|
||||
const permissions = await getAllPermissions();
|
||||
this._onPermissionsChanged({permissions});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} origin
|
||||
* @param {boolean} enabled
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _setOriginPermissionEnabled(origin, enabled) {
|
||||
let added = false;
|
||||
try {
|
||||
added = await setPermissionsGranted({origins: [origin]}, enabled);
|
||||
} catch (e) {
|
||||
const errorContainer = /** @type {HTMLElement} */ (this._errorContainer);
|
||||
errorContainer.hidden = false;
|
||||
errorContainer.textContent = toError(e).message;
|
||||
}
|
||||
if (!added) { return false; }
|
||||
await this._updatePermissions();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
162
vendor/yomitan/js/pages/settings/permissions-toggle-controller.js
vendored
Normal file
162
vendor/yomitan/js/pages/settings/permissions-toggle-controller.js
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {getAllPermissions, hasPermissions, setPermissionsGranted} from '../../data/permissions-util.js';
|
||||
import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js';
|
||||
|
||||
export class PermissionsToggleController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {?NodeListOf<HTMLInputElement>} */
|
||||
this._toggles = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._toggles = document.querySelectorAll('.permissions-toggle');
|
||||
|
||||
for (const toggle of this._toggles) {
|
||||
toggle.addEventListener('change', this._onPermissionsToggleChange.bind(this), false);
|
||||
}
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this));
|
||||
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
let accessor = null;
|
||||
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (this._toggles)) {
|
||||
const {permissionsSetting} = toggle.dataset;
|
||||
if (typeof permissionsSetting !== 'string') { continue; }
|
||||
|
||||
if (accessor === null) {
|
||||
accessor = new ObjectPropertyAccessor(options);
|
||||
}
|
||||
|
||||
const path = ObjectPropertyAccessor.getPathArray(permissionsSetting);
|
||||
let value;
|
||||
try {
|
||||
value = accessor.get(path, path.length);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
toggle.checked = !!value;
|
||||
}
|
||||
void this._updateValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
async _onPermissionsToggleChange(e) {
|
||||
const toggle = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
let value = toggle.checked;
|
||||
const valuePre = !value;
|
||||
const {permissionsSetting} = toggle.dataset;
|
||||
const hasPermissionsSetting = typeof permissionsSetting === 'string';
|
||||
|
||||
if (value || !hasPermissionsSetting) {
|
||||
toggle.checked = valuePre;
|
||||
const permissions = this._getRequiredPermissions(toggle);
|
||||
try {
|
||||
value = await setPermissionsGranted({permissions}, value);
|
||||
} catch (error) {
|
||||
value = valuePre;
|
||||
try {
|
||||
value = await hasPermissions({permissions});
|
||||
} catch (error2) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
toggle.checked = value;
|
||||
}
|
||||
|
||||
if (hasPermissionsSetting) {
|
||||
this._setToggleValid(toggle, true);
|
||||
await this._settingsController.setProfileSetting(permissionsSetting, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'permissionsChanged'>} details
|
||||
*/
|
||||
_onPermissionsChanged({permissions}) {
|
||||
const permissions2 = permissions.permissions;
|
||||
const permissionsSet = new Set(typeof permissions2 !== 'undefined' ? permissions2 : []);
|
||||
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (this._toggles)) {
|
||||
const {permissionsSetting} = toggle.dataset;
|
||||
const hasPermissions2 = this._hasAll(permissionsSet, this._getRequiredPermissions(toggle));
|
||||
|
||||
if (typeof permissionsSetting === 'string') {
|
||||
const valid = !toggle.checked || hasPermissions2;
|
||||
this._setToggleValid(toggle, valid);
|
||||
} else {
|
||||
toggle.checked = hasPermissions2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLInputElement} toggle
|
||||
* @param {boolean} valid
|
||||
*/
|
||||
_setToggleValid(toggle, valid) {
|
||||
const relative = /** @type {?HTMLElement} */ (toggle.closest('.settings-item'));
|
||||
if (relative === null) { return; }
|
||||
relative.dataset.invalid = `${!valid}`;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateValidity() {
|
||||
const permissions = await getAllPermissions();
|
||||
this._onPermissionsChanged({permissions});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string>} set
|
||||
* @param {string[]} values
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_hasAll(set, values) {
|
||||
for (const value of values) {
|
||||
if (!set.has(value)) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLInputElement} toggle
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_getRequiredPermissions(toggle) {
|
||||
const requiredPermissions = toggle.dataset.requiredPermissions;
|
||||
return (typeof requiredPermissions === 'string' && requiredPermissions.length > 0 ? requiredPermissions.split(' ') : []);
|
||||
}
|
||||
}
|
||||
111
vendor/yomitan/js/pages/settings/persistent-storage-controller.js
vendored
Normal file
111
vendor/yomitan/js/pages/settings/persistent-storage-controller.js
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {isObjectNotArray} from '../../core/object-utilities.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class PersistentStorageController {
|
||||
/**
|
||||
* @param {import('../../application.js').Application} application
|
||||
*/
|
||||
constructor(application) {
|
||||
/** @type {import('../../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {HTMLInputElement} */
|
||||
this._persistentStorageCheckbox = querySelectorNotNull(document, '#storage-persistent-checkbox');
|
||||
}
|
||||
|
||||
/** @type {import('../../application.js').Application} */
|
||||
get application() {
|
||||
return this._application;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._persistentStorageCheckbox.addEventListener('change', this._onPersistentStorageCheckboxChange.bind(this), false);
|
||||
|
||||
if (!this._isPersistentStorageSupported()) { return; }
|
||||
|
||||
/** @type {?HTMLElement} */
|
||||
const info = document.querySelector('#storage-persistent-info');
|
||||
if (info !== null) { info.hidden = false; }
|
||||
|
||||
const isStoragePeristent = await this.isStoragePeristent();
|
||||
this._updateCheckbox(isStoragePeristent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isStoragePeristent() {
|
||||
try {
|
||||
return await navigator.storage.persisted();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onPersistentStorageCheckboxChange(e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
if (node.checked) {
|
||||
node.checked = false;
|
||||
void this._attemptPersistStorage();
|
||||
} else {
|
||||
node.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _attemptPersistStorage() {
|
||||
let isStoragePeristent = false;
|
||||
try {
|
||||
isStoragePeristent = await navigator.storage.persist();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
this._updateCheckbox(isStoragePeristent);
|
||||
|
||||
/** @type {?HTMLElement} */
|
||||
const node = document.querySelector('#storage-persistent-fail-warning');
|
||||
if (node !== null) { node.hidden = isStoragePeristent; }
|
||||
|
||||
this._application.triggerStorageChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isPersistentStorageSupported() {
|
||||
return isObjectNotArray(navigator.storage) && typeof navigator.storage.persist === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isStoragePeristent
|
||||
*/
|
||||
_updateCheckbox(isStoragePeristent) {
|
||||
/** @type {HTMLInputElement} */ (this._persistentStorageCheckbox).checked = isStoragePeristent;
|
||||
/** @type {HTMLInputElement} */ (this._persistentStorageCheckbox).readOnly = isStoragePeristent;
|
||||
}
|
||||
}
|
||||
128
vendor/yomitan/js/pages/settings/popup-preview-controller.js
vendored
Normal file
128
vendor/yomitan/js/pages/settings/popup-preview-controller.js
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class PopupPreviewController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {string} */
|
||||
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
|
||||
/** @type {HTMLIFrameElement} */
|
||||
this._frame = querySelectorNotNull(document, '#popup-preview-frame');
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
this._customCss = querySelectorNotNull(document, '#custom-popup-css');
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
this._customOuterCss = querySelectorNotNull(document, '#custom-popup-outer-css');
|
||||
/** @type {HTMLElement} */
|
||||
this._previewFrameContainer = querySelectorNotNull(document, '.preview-frame-container');
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
if (new URLSearchParams(location.search).get('popup-preview') === 'false') { return; }
|
||||
|
||||
this._customCss.addEventListener('input', this._onCustomCssChange.bind(this), false);
|
||||
this._customCss.addEventListener('settingChanged', this._onCustomCssChange.bind(this), false);
|
||||
this._customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false);
|
||||
this._customOuterCss.addEventListener('settingChanged', this._onCustomOuterCssChange.bind(this), false);
|
||||
this._frame.addEventListener('load', this._onFrameLoad.bind(this), false);
|
||||
this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
this._settingsController.on('dictionaryEnabled', this._onOptionsContextChange.bind(this));
|
||||
const languageSelect = querySelectorNotNull(document, '#language-select');
|
||||
languageSelect.addEventListener(
|
||||
/** @type {string} */ ('settingChanged'),
|
||||
/** @type {EventListener} */ (this._onLanguageSelectChanged.bind(this)),
|
||||
false,
|
||||
);
|
||||
|
||||
|
||||
this._frame.src = '/popup-preview.html';
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onFrameLoad() {
|
||||
this._onOptionsContextChange();
|
||||
this._onCustomCssChange();
|
||||
this._onCustomOuterCssChange();
|
||||
}
|
||||
|
||||
/** */
|
||||
_onCustomCssChange() {
|
||||
const css = /** @type {HTMLTextAreaElement} */ (this._customCss).value;
|
||||
this._invoke('setCustomCss', {css});
|
||||
}
|
||||
|
||||
/** */
|
||||
_onCustomOuterCssChange() {
|
||||
const css = /** @type {HTMLTextAreaElement} */ (this._customOuterCss).value;
|
||||
this._invoke('setCustomOuterCss', {css});
|
||||
}
|
||||
|
||||
/** */
|
||||
_onOptionsContextChange() {
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._invoke('updateOptionsContext', {optionsContext});
|
||||
}
|
||||
|
||||
/** */
|
||||
_onDictionaryEnabled() {
|
||||
this._invoke('updateSearch', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
this._invoke('setLanguageExampleText', {language: options.general.language});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').SettingChangedEvent} settingChangedEvent
|
||||
*/
|
||||
_onLanguageSelectChanged(settingChangedEvent) {
|
||||
const {value} = settingChangedEvent.detail;
|
||||
if (typeof value !== 'string') { return; }
|
||||
this._invoke('setLanguageExampleText', {language: value});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('popup-preview-frame').ApiNames} TName
|
||||
* @param {TName} action
|
||||
* @param {import('popup-preview-frame').ApiParams<TName>} params
|
||||
*/
|
||||
_invoke(action, params) {
|
||||
if (this._frame === null || this._frame.contentWindow === null) { return; }
|
||||
this._frame.contentWindow.postMessage({action, params}, this._targetOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkPopupPreviewURL(url) {
|
||||
return !!(url && url.includes('popup-preview.html') && !['http:', 'https:', 'ws:', 'wss:', 'ftp:', 'data:', 'file:'].includes(new URL(url).protocol));
|
||||
}
|
||||
35
vendor/yomitan/js/pages/settings/popup-preview-frame-main.js
vendored
Normal file
35
vendor/yomitan/js/pages/settings/popup-preview-frame-main.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {PopupFactory} from '../../app/popup-factory.js';
|
||||
import {Application} from '../../application.js';
|
||||
import {HotkeyHandler} from '../../input/hotkey-handler.js';
|
||||
import {PopupPreviewFrame} from './popup-preview-frame.js';
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const hotkeyHandler = new HotkeyHandler();
|
||||
hotkeyHandler.prepare(application.crossFrame);
|
||||
|
||||
const popupFactory = new PopupFactory(application);
|
||||
popupFactory.prepare();
|
||||
|
||||
const preview = new PopupPreviewFrame(application, popupFactory, hotkeyHandler);
|
||||
await preview.prepare();
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
322
vendor/yomitan/js/pages/settings/popup-preview-frame.js
vendored
Normal file
322
vendor/yomitan/js/pages/settings/popup-preview-frame.js
vendored
Normal file
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Frontend} from '../../app/frontend.js';
|
||||
import {ThemeController} from '../../app/theme-controller.js';
|
||||
import {createApiMap, invokeApiMapHandler} from '../../core/api-map.js';
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {TextSourceRange} from '../../dom/text-source-range.js';
|
||||
import {isComposing} from '../../language/ime-utilities.js';
|
||||
import {convertToKanaIME} from '../../language/ja/japanese-wanakana.js';
|
||||
|
||||
export class PopupPreviewFrame {
|
||||
/**
|
||||
* @param {import('../../application.js').Application} application
|
||||
* @param {import('../../app/popup-factory.js').PopupFactory} popupFactory
|
||||
* @param {import('../../input/hotkey-handler.js').HotkeyHandler} hotkeyHandler
|
||||
*/
|
||||
constructor(application, popupFactory, hotkeyHandler) {
|
||||
/** @type {import('../../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {import('../../app/popup-factory.js').PopupFactory} */
|
||||
this._popupFactory = popupFactory;
|
||||
/** @type {import('../../input/hotkey-handler.js').HotkeyHandler} */
|
||||
this._hotkeyHandler = hotkeyHandler;
|
||||
/** @type {?Frontend} */
|
||||
this._frontend = null;
|
||||
/** @type {?(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */
|
||||
this._apiOptionsGetOld = null;
|
||||
/** @type {boolean} */
|
||||
this._popupShown = false;
|
||||
/** @type {?import('core').Timeout} */
|
||||
this._themeChangeTimeout = null;
|
||||
/** @type {?import('text-source').TextSource} */
|
||||
this._textSource = null;
|
||||
/** @type {?import('settings').OptionsContext} */
|
||||
this._optionsContext = null;
|
||||
/** @type {HTMLElement} */
|
||||
this._exampleText = querySelectorNotNull(document, '#example-text');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._exampleTextInput = querySelectorNotNull(document, '#example-text-input');
|
||||
/** @type {EventListenerCollection} */
|
||||
this._exampleTextInputEvents = new EventListenerCollection();
|
||||
/** @type {string} */
|
||||
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
|
||||
/** @type {import('language').LanguageSummary[]} */
|
||||
this._languageSummaries = [];
|
||||
/** @type {ThemeController} */
|
||||
this._themeController = new ThemeController(document.documentElement);
|
||||
|
||||
/* eslint-disable @stylistic/no-multi-spaces */
|
||||
/** @type {import('popup-preview-frame').ApiMap} */
|
||||
this._windowMessageHandlers = createApiMap([
|
||||
['setText', this._onSetText.bind(this)],
|
||||
['setCustomCss', this._setCustomCss.bind(this)],
|
||||
['setCustomOuterCss', this._setCustomOuterCss.bind(this)],
|
||||
['updateOptionsContext', this._updateOptionsContext.bind(this)],
|
||||
['setLanguageExampleText', this._setLanguageExampleText.bind(this)],
|
||||
['updateSearch', this._updateSearch.bind(this)],
|
||||
]);
|
||||
/* eslint-enable @stylistic/no-multi-spaces */
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
window.addEventListener('message', this._onMessage.bind(this), false);
|
||||
|
||||
this._themeController.prepare();
|
||||
|
||||
// Setup events
|
||||
this._exampleText.addEventListener('click', this._onExampleTextClick.bind(this), false);
|
||||
this._exampleTextInput.addEventListener('blur', this._onExampleTextInputBlur.bind(this), false);
|
||||
this._exampleTextInput.addEventListener('input', this._onExampleTextInputInput.bind(this), false);
|
||||
|
||||
// Overwrite API functions
|
||||
/** @type {?(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */
|
||||
this._apiOptionsGetOld = this._application.api.optionsGet.bind(this._application.api);
|
||||
this._application.api.optionsGet = this._apiOptionsGet.bind(this);
|
||||
|
||||
this._languageSummaries = await this._application.api.getLanguageSummaries();
|
||||
const options = await this._application.api.optionsGet({current: true});
|
||||
void this._setLanguageExampleText({language: options.general.language});
|
||||
|
||||
// Overwrite frontend
|
||||
this._frontend = new Frontend({
|
||||
application: this._application,
|
||||
popupFactory: this._popupFactory,
|
||||
depth: 0,
|
||||
parentPopupId: null,
|
||||
parentFrameId: null,
|
||||
useProxyPopup: false,
|
||||
canUseWindowPopup: false,
|
||||
pageType: 'web',
|
||||
allowRootFramePopupProxy: false,
|
||||
childrenSupported: false,
|
||||
hotkeyHandler: this._hotkeyHandler,
|
||||
});
|
||||
this._frontend.setOptionsContextOverride(this._optionsContext);
|
||||
await this._frontend.prepare();
|
||||
this._frontend.setDisabledOverride(true);
|
||||
this._frontend.canClearSelection = false;
|
||||
const {popup} = this._frontend;
|
||||
if (popup !== null) {
|
||||
popup.on('customOuterCssChanged', this._onCustomOuterCssChanged.bind(this));
|
||||
}
|
||||
|
||||
// Update search
|
||||
void this._updateSearch();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings').OptionsContext} optionsContext
|
||||
* @returns {Promise<import('settings').ProfileOptions>}
|
||||
*/
|
||||
async _apiOptionsGet(optionsContext) {
|
||||
const options = await /** @type {(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */ (this._apiOptionsGetOld)(optionsContext);
|
||||
options.general.enable = true;
|
||||
options.general.debugInfo = false;
|
||||
options.general.popupWidth = 400;
|
||||
options.general.popupHeight = 250;
|
||||
options.general.popupHorizontalOffset = 0;
|
||||
options.general.popupVerticalOffset = 10;
|
||||
options.general.popupHorizontalOffset2 = 10;
|
||||
options.general.popupVerticalOffset2 = 0;
|
||||
options.general.popupHorizontalTextPosition = 'below';
|
||||
options.general.popupVerticalTextPosition = 'before';
|
||||
options.scanning.selectText = false;
|
||||
this._themeController.theme = options.general.popupTheme;
|
||||
this._themeController.siteOverride = true;
|
||||
this._themeController.updateTheme();
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup').EventArgument<'customOuterCssChanged'>} details
|
||||
*/
|
||||
_onCustomOuterCssChanged({node, inShadow}) {
|
||||
if (node === null || inShadow) { return; }
|
||||
|
||||
const node2 = document.querySelector('#popup-outer-css');
|
||||
if (node2 === null) { return; }
|
||||
const {parentNode} = node2;
|
||||
if (parentNode === null) { return; }
|
||||
|
||||
// This simulates the stylesheet priorities when injecting using the web extension API.
|
||||
parentNode.insertBefore(node, node2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<import('popup-preview-frame.js').ApiMessageAny>} event
|
||||
*/
|
||||
_onMessage(event) {
|
||||
if (event.origin !== this._targetOrigin) { return; }
|
||||
const {action, params} = event.data;
|
||||
const callback = () => {}; // NOP
|
||||
invokeApiMapHandler(this._windowMessageHandlers, action, params, [], callback);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onExampleTextClick() {
|
||||
if (this._exampleTextInput === null) { return; }
|
||||
const visible = this._exampleTextInput.hidden;
|
||||
this._exampleTextInput.hidden = !visible;
|
||||
if (!visible) { return; }
|
||||
this._exampleTextInput.focus();
|
||||
this._exampleTextInput.select();
|
||||
}
|
||||
|
||||
/** */
|
||||
_onExampleTextInputBlur() {
|
||||
if (this._exampleTextInput === null) { return; }
|
||||
this._exampleTextInput.hidden = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onExampleTextInputInput(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
this._setText(element.value, false);
|
||||
}
|
||||
|
||||
/** @type {import('popup-preview-frame').ApiHandler<'setText'>} */
|
||||
_onSetText({text}) {
|
||||
this._setText(text, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {boolean} setInput
|
||||
*/
|
||||
_setText(text, setInput) {
|
||||
if (setInput && this._exampleTextInput !== null) {
|
||||
this._exampleTextInput.value = text;
|
||||
}
|
||||
|
||||
if (this._exampleText === null) { return; }
|
||||
|
||||
this._exampleText.textContent = text;
|
||||
if (this._frontend === null) { return; }
|
||||
void this._updateSearch();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
_setInfoVisible(visible) {
|
||||
const node = document.querySelector('.placeholder-info');
|
||||
if (node === null) { return; }
|
||||
|
||||
node.classList.toggle('placeholder-info-visible', visible);
|
||||
}
|
||||
|
||||
/** @type {import('popup-preview-frame').ApiHandler<'setCustomCss'>} */
|
||||
_setCustomCss({css}) {
|
||||
if (this._frontend === null) { return; }
|
||||
const popup = this._frontend.popup;
|
||||
if (popup === null) { return; }
|
||||
void popup.setCustomCss(css);
|
||||
}
|
||||
|
||||
/** @type {import('popup-preview-frame').ApiHandler<'setCustomOuterCss'>} */
|
||||
_setCustomOuterCss({css}) {
|
||||
if (this._frontend === null) { return; }
|
||||
const popup = this._frontend.popup;
|
||||
if (popup === null) { return; }
|
||||
void popup.setCustomOuterCss(css, false);
|
||||
}
|
||||
|
||||
/** @type {import('popup-preview-frame').ApiHandler<'updateOptionsContext'>} */
|
||||
async _updateOptionsContext(details) {
|
||||
const {optionsContext} = details;
|
||||
this._optionsContext = optionsContext;
|
||||
if (this._frontend === null) { return; }
|
||||
this._frontend.setOptionsContextOverride(optionsContext);
|
||||
await this._frontend.updateOptions();
|
||||
await this._updateSearch();
|
||||
}
|
||||
|
||||
/** @type {import('popup-preview-frame').ApiHandler<'setLanguageExampleText'>} */
|
||||
_setLanguageExampleText({language}) {
|
||||
const activeLanguage = /** @type {import('language').LanguageSummary} */ (this._languageSummaries.find(({iso}) => iso === language));
|
||||
|
||||
this._exampleTextInputEvents.removeAllEventListeners();
|
||||
if (this._exampleTextInput !== null && language === 'ja') {
|
||||
this._exampleTextInputEvents.addEventListener(this._exampleTextInput, 'input', this._onSearchInput.bind(this), false);
|
||||
}
|
||||
|
||||
this._exampleTextInput.lang = language;
|
||||
this._exampleTextInput.value = activeLanguage.exampleText;
|
||||
this._exampleTextInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputEvent} e
|
||||
*/
|
||||
_onSearchInput(e) {
|
||||
const element = /** @type {HTMLTextAreaElement} */ (e.currentTarget);
|
||||
this._searchTextKanaConversion(element, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} element
|
||||
* @param {InputEvent} event
|
||||
*/
|
||||
_searchTextKanaConversion(element, event) {
|
||||
const platform = document.documentElement.dataset.platform ?? 'unknown';
|
||||
const browser = document.documentElement.dataset.browser ?? 'unknown';
|
||||
if (isComposing(event, platform, browser)) { return; }
|
||||
|
||||
const {kanaString, newSelectionStart} = convertToKanaIME(element.value, element.selectionStart);
|
||||
element.value = kanaString;
|
||||
element.setSelectionRange(newSelectionStart, newSelectionStart);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateSearch() {
|
||||
if (this._exampleText === null) { return; }
|
||||
|
||||
const textNode = this._exampleText.firstChild;
|
||||
if (textNode === null) { return; }
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(textNode);
|
||||
const source = TextSourceRange.create(range);
|
||||
const frontend = /** @type {Frontend} */ (this._frontend);
|
||||
|
||||
try {
|
||||
await frontend.setTextSource(source);
|
||||
} finally {
|
||||
source.cleanup();
|
||||
}
|
||||
this._textSource = source;
|
||||
await frontend.showContentCompleted();
|
||||
|
||||
const popup = frontend.popup;
|
||||
if (popup !== null && popup.isVisibleSync()) {
|
||||
this._popupShown = true;
|
||||
}
|
||||
|
||||
this._setInfoVisible(!this._popupShown);
|
||||
|
||||
this._themeController.updateTheme();
|
||||
}
|
||||
}
|
||||
51
vendor/yomitan/js/pages/settings/popup-window-controller.js
vendored
Normal file
51
vendor/yomitan/js/pages/settings/popup-window-controller.js
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class PopupWindowController {
|
||||
/**
|
||||
* @param {import('../../comm/api.js').API} api
|
||||
*/
|
||||
constructor(api) {
|
||||
/** @type {import('../../comm/api.js').API} */
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
/** @type {HTMLElement} */
|
||||
const testLink = querySelectorNotNull(document, '#test-window-open-link');
|
||||
testLink.addEventListener('click', this._onTestWindowOpenLinkClick.bind(this), false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onTestWindowOpenLinkClick(e) {
|
||||
e.preventDefault();
|
||||
void this._testWindowOpen();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _testWindowOpen() {
|
||||
await this._api.getOrCreateSearchPopup({focus: true});
|
||||
}
|
||||
}
|
||||
1003
vendor/yomitan/js/pages/settings/profile-conditions-ui.js
vendored
Normal file
1003
vendor/yomitan/js/pages/settings/profile-conditions-ui.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
848
vendor/yomitan/js/pages/settings/profile-controller.js
vendored
Normal file
848
vendor/yomitan/js/pages/settings/profile-controller.js
vendored
Normal file
@@ -0,0 +1,848 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {clone, generateId} from '../../core/utilities.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {ProfileConditionsUI} from './profile-conditions-ui.js';
|
||||
|
||||
export class ProfileController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {import('./modal-controller.js').ModalController} modalController
|
||||
*/
|
||||
constructor(settingsController, modalController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {import('./modal-controller.js').ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {ProfileConditionsUI} */
|
||||
this._profileConditionsUI = new ProfileConditionsUI(settingsController);
|
||||
/** @type {?number} */
|
||||
this._profileConditionsIndex = null;
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._profileActiveSelect = querySelectorNotNull(document, '#profile-active-select');
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._profileCopySourceSelect = querySelectorNotNull(document, '#profile-copy-source-select');
|
||||
/** @type {HTMLElement} */
|
||||
this._resetProfileNameElement = querySelectorNotNull(document, '#profile-reset-name');
|
||||
/** @type {HTMLElement} */
|
||||
this._removeProfileNameElement = querySelectorNotNull(document, '#profile-remove-name');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._profileAddButton = querySelectorNotNull(document, '#profile-add-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._profileResetConfirmButton = querySelectorNotNull(document, '#profile-reset-confirm-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._profileRemoveConfirmButton = querySelectorNotNull(document, '#profile-remove-confirm-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._profileCopyConfirmButton = querySelectorNotNull(document, '#profile-copy-confirm-button');
|
||||
/** @type {HTMLElement} */
|
||||
this._profileEntryListContainer = querySelectorNotNull(document, '#profile-entry-list');
|
||||
/** @type {HTMLElement} */
|
||||
this._profileConditionsProfileName = querySelectorNotNull(document, '#profile-conditions-profile-name');
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._profileRemoveModal = null;
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._profileCopyModal = null;
|
||||
/** @type {?import('./modal.js').Modal} */
|
||||
this._profileConditionsModal = null;
|
||||
/** @type {boolean} */
|
||||
this._profileEntriesSupported = false;
|
||||
/** @type {ProfileEntry[]} */
|
||||
this._profileEntryList = [];
|
||||
/** @type {import('settings').Profile[]} */
|
||||
this._profiles = [];
|
||||
/** @type {number} */
|
||||
this._profileCurrent = 0;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get profileCount() {
|
||||
return this._profiles.length;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get profileCurrentIndex() {
|
||||
return this._profileCurrent;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
|
||||
this._profileConditionsUI.os = os;
|
||||
|
||||
this._profileResetModal = this._modalController.getModal('profile-reset');
|
||||
this._profileRemoveModal = this._modalController.getModal('profile-remove');
|
||||
this._profileCopyModal = this._modalController.getModal('profile-copy');
|
||||
this._profileConditionsModal = this._modalController.getModal('profile-conditions');
|
||||
|
||||
this._profileEntriesSupported = (this._profileEntryListContainer !== null);
|
||||
|
||||
if (this._profileActiveSelect !== null) { this._profileActiveSelect.addEventListener('change', this._onProfileActiveChange.bind(this), false); }
|
||||
if (this._profileAddButton !== null) { this._profileAddButton.addEventListener('click', this._onAdd.bind(this), false); }
|
||||
if (this._profileResetConfirmButton !== null) { this._profileResetConfirmButton.addEventListener('click', this._onResetConfirm.bind(this), false); }
|
||||
if (this._profileRemoveConfirmButton !== null) { this._profileRemoveConfirmButton.addEventListener('click', this._onDeleteConfirm.bind(this), false); }
|
||||
if (this._profileCopyConfirmButton !== null) { this._profileCopyConfirmButton.addEventListener('click', this._onCopyConfirm.bind(this), false); }
|
||||
|
||||
this._profileConditionsUI.on('conditionGroupCountChanged', this._onConditionGroupCountChanged.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
void this._onOptionsChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
* @param {number} offset
|
||||
*/
|
||||
async moveProfile(profileIndex, offset) {
|
||||
if (this._getProfile(profileIndex) === null) { return; }
|
||||
|
||||
const profileIndexNew = Math.max(0, Math.min(this._profiles.length - 1, profileIndex + offset));
|
||||
if (profileIndex === profileIndexNew) { return; }
|
||||
|
||||
await this.swapProfiles(profileIndex, profileIndexNew);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
* @param {string} value
|
||||
*/
|
||||
async setProfileName(profileIndex, value) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null) { return; }
|
||||
|
||||
profile.name = value;
|
||||
this._updateSelectName(profileIndex, value);
|
||||
|
||||
const profileEntry = this._getProfileEntry(profileIndex);
|
||||
if (profileEntry !== null) { profileEntry.setName(value); }
|
||||
|
||||
await this._settingsController.setGlobalSetting(`profiles[${profileIndex}].name`, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
async setDefaultProfile(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null) { return; }
|
||||
|
||||
/** @type {HTMLSelectElement} */ (this._profileActiveSelect).value = `${profileIndex}`;
|
||||
this._profileCurrent = profileIndex;
|
||||
|
||||
const profileEntry = this._getProfileEntry(profileIndex);
|
||||
if (profileEntry !== null) { profileEntry.setIsDefault(true); }
|
||||
|
||||
this._settingsController.profileIndex = profileIndex;
|
||||
await this._settingsController.setGlobalSetting('profileCurrent', profileIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} sourceProfileIndex
|
||||
* @param {number} destinationProfileIndex
|
||||
*/
|
||||
async copyProfile(sourceProfileIndex, destinationProfileIndex) {
|
||||
const sourceProfile = this._getProfile(sourceProfileIndex);
|
||||
if (sourceProfile === null || !this._getProfile(destinationProfileIndex)) { return; }
|
||||
|
||||
const options = clone(sourceProfile.options);
|
||||
this._profiles[destinationProfileIndex].options = options;
|
||||
|
||||
this._updateProfileSelectOptions();
|
||||
|
||||
const destinationProfileEntry = this._getProfileEntry(destinationProfileIndex);
|
||||
if (destinationProfileEntry !== null) {
|
||||
destinationProfileEntry.updateState();
|
||||
}
|
||||
|
||||
await this._settingsController.modifyGlobalSettings([{
|
||||
action: 'set',
|
||||
path: `profiles[${destinationProfileIndex}].options`,
|
||||
value: options,
|
||||
}]);
|
||||
|
||||
await this._settingsController.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
async duplicateProfile(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null) { return; }
|
||||
|
||||
// Create new profile
|
||||
const newProfile = clone(profile);
|
||||
newProfile.name = this._createCopyName(profile.name, this._profiles, 100);
|
||||
newProfile.id = generateId(16);
|
||||
|
||||
// Update state
|
||||
const index = this._profiles.length;
|
||||
this._profiles.push(newProfile);
|
||||
if (this._profileEntriesSupported) {
|
||||
this._addProfileEntry(index);
|
||||
}
|
||||
this._updateProfileSelectOptions();
|
||||
|
||||
// Modify settings
|
||||
await this._settingsController.modifyGlobalSettings([{
|
||||
action: 'splice',
|
||||
path: 'profiles',
|
||||
start: index,
|
||||
deleteCount: 0,
|
||||
items: [newProfile],
|
||||
}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
async resetProfile(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null) { return; }
|
||||
|
||||
const defaultOptions = await this._settingsController.getDefaultOptions();
|
||||
const defaultProfileOptions = defaultOptions.profiles[0];
|
||||
defaultProfileOptions.name = profile.name;
|
||||
|
||||
await this._settingsController.modifyGlobalSettings([{
|
||||
action: 'set',
|
||||
path: `profiles[${profileIndex}]`,
|
||||
value: defaultProfileOptions,
|
||||
}]);
|
||||
|
||||
await this._settingsController.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
async deleteProfile(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null || this.profileCount <= 1) { return; }
|
||||
|
||||
// Get indices
|
||||
let profileCurrentNew = this._profileCurrent;
|
||||
const settingsProfileIndex = this._profileCurrent;
|
||||
|
||||
// Construct settings modifications
|
||||
/** @type {import('settings-modifications').Modification[]} */
|
||||
const modifications = [{
|
||||
action: 'splice',
|
||||
path: 'profiles',
|
||||
start: profileIndex,
|
||||
deleteCount: 1,
|
||||
items: [],
|
||||
}];
|
||||
if (profileCurrentNew >= profileIndex) {
|
||||
profileCurrentNew = Math.min(profileCurrentNew - 1, this._profiles.length - 1);
|
||||
modifications.push({
|
||||
action: 'set',
|
||||
path: 'profileCurrent',
|
||||
value: profileCurrentNew,
|
||||
});
|
||||
}
|
||||
|
||||
// Update state
|
||||
this._profileCurrent = profileCurrentNew;
|
||||
|
||||
this._profiles.splice(profileIndex, 1);
|
||||
|
||||
if (profileIndex < this._profileEntryList.length) {
|
||||
const profileEntry = this._profileEntryList[profileIndex];
|
||||
profileEntry.cleanup();
|
||||
this._profileEntryList.splice(profileIndex, 1);
|
||||
|
||||
for (let i = profileIndex, ii = this._profileEntryList.length; i < ii; ++i) {
|
||||
this._profileEntryList[i].index = i;
|
||||
}
|
||||
}
|
||||
|
||||
const profileEntry2 = this._getProfileEntry(profileCurrentNew);
|
||||
if (profileEntry2 !== null) {
|
||||
profileEntry2.setIsDefault(true);
|
||||
}
|
||||
|
||||
this._updateProfileSelectOptions();
|
||||
|
||||
// Update profile index
|
||||
if (settingsProfileIndex >= profileIndex) {
|
||||
this._settingsController.profileIndex = settingsProfileIndex - 1;
|
||||
} else {
|
||||
this._settingsController.refreshProfileIndex();
|
||||
}
|
||||
|
||||
// Modify settings
|
||||
await this._settingsController.modifyGlobalSettings(modifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index1
|
||||
* @param {number} index2
|
||||
*/
|
||||
async swapProfiles(index1, index2) {
|
||||
const profile1 = this._getProfile(index1);
|
||||
const profile2 = this._getProfile(index2);
|
||||
if (profile1 === null || profile2 === null || index1 === index2) { return; }
|
||||
|
||||
// Get swapped indices
|
||||
const profileCurrent = this._profileCurrent;
|
||||
const profileCurrentNew = this._getSwappedValue(profileCurrent, index1, index2);
|
||||
|
||||
const settingsProfileIndex = this._settingsController.profileIndex;
|
||||
const settingsProfileIndexNew = this._getSwappedValue(settingsProfileIndex, index1, index2);
|
||||
|
||||
// Construct settings modifications
|
||||
/** @type {import('settings-modifications').Modification[]} */
|
||||
const modifications = [{
|
||||
action: 'swap',
|
||||
path1: `profiles[${index1}]`,
|
||||
path2: `profiles[${index2}]`,
|
||||
}];
|
||||
if (profileCurrentNew !== profileCurrent) {
|
||||
modifications.push({
|
||||
action: 'set',
|
||||
path: 'profileCurrent',
|
||||
value: profileCurrentNew,
|
||||
});
|
||||
}
|
||||
|
||||
// Update state
|
||||
this._profileCurrent = profileCurrentNew;
|
||||
|
||||
this._profiles[index1] = profile2;
|
||||
this._profiles[index2] = profile1;
|
||||
|
||||
const entry1 = this._getProfileEntry(index1);
|
||||
const entry2 = this._getProfileEntry(index2);
|
||||
if (entry1 !== null && entry2 !== null) {
|
||||
entry1.index = index2;
|
||||
entry2.index = index1;
|
||||
this._swapDomNodes(entry1.node, entry2.node);
|
||||
this._profileEntryList[index1] = entry2;
|
||||
this._profileEntryList[index2] = entry1;
|
||||
}
|
||||
|
||||
this._updateProfileSelectOptions();
|
||||
|
||||
// Modify settings
|
||||
await this._settingsController.modifyGlobalSettings(modifications);
|
||||
|
||||
// Update profile index
|
||||
if (settingsProfileIndex !== settingsProfileIndexNew) {
|
||||
this._settingsController.profileIndex = settingsProfileIndexNew;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
openResetProfileModal(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null || this.profileCount <= 1) { return; }
|
||||
|
||||
/** @type {HTMLElement} */ (this._resetProfileNameElement).textContent = profile.name;
|
||||
/** @type {import('./modal.js').Modal} */ (this._profileResetModal).node.dataset.profileIndex = `${profileIndex}`;
|
||||
/** @type {import('./modal.js').Modal} */ (this._profileResetModal).setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
openDeleteProfileModal(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null || this.profileCount <= 1) { return; }
|
||||
|
||||
/** @type {HTMLElement} */ (this._removeProfileNameElement).textContent = profile.name;
|
||||
/** @type {import('./modal.js').Modal} */ (this._profileRemoveModal).node.dataset.profileIndex = `${profileIndex}`;
|
||||
/** @type {import('./modal.js').Modal} */ (this._profileRemoveModal).setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
openCopyProfileModal(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null || this.profileCount <= 1) { return; }
|
||||
|
||||
let copyFromIndex = this._profileCurrent;
|
||||
if (copyFromIndex === profileIndex) {
|
||||
if (profileIndex !== 0) {
|
||||
copyFromIndex = 0;
|
||||
} else if (this.profileCount > 1) {
|
||||
copyFromIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const profileIndexString = `${profileIndex}`;
|
||||
const select = /** @type {HTMLSelectElement} */ (this._profileCopySourceSelect);
|
||||
for (const option of select.querySelectorAll('option')) {
|
||||
const {value} = option;
|
||||
option.disabled = (value === profileIndexString);
|
||||
}
|
||||
select.value = `${copyFromIndex}`;
|
||||
|
||||
/** @type {import('./modal.js').Modal} */ (this._profileCopyModal).node.dataset.profileIndex = `${profileIndex}`;
|
||||
/** @type {import('./modal.js').Modal} */ (this._profileCopyModal).setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
openProfileConditionsModal(profileIndex) {
|
||||
const profile = this._getProfile(profileIndex);
|
||||
if (profile === null) { return; }
|
||||
|
||||
if (this._profileConditionsModal === null) { return; }
|
||||
this._profileConditionsModal.setVisible(true);
|
||||
|
||||
this._profileConditionsUI.cleanup();
|
||||
this._profileConditionsIndex = profileIndex;
|
||||
void this._profileConditionsUI.prepare(profileIndex);
|
||||
if (this._profileConditionsProfileName !== null) {
|
||||
this._profileConditionsProfileName.textContent = profile.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _onOptionsChanged() {
|
||||
// Update state
|
||||
const {profiles, profileCurrent} = await this._settingsController.getOptionsFull();
|
||||
this._profiles = profiles;
|
||||
this._profileCurrent = profileCurrent;
|
||||
|
||||
const settingsProfileIndex = this._settingsController.profileIndex;
|
||||
|
||||
// Update UI
|
||||
this._updateProfileSelectOptions();
|
||||
void this.setDefaultProfile(profileCurrent);
|
||||
|
||||
/** @type {HTMLSelectElement} */ (this._profileActiveSelect).value = `${profileCurrent}`;
|
||||
|
||||
// Update profile conditions
|
||||
this._profileConditionsUI.cleanup();
|
||||
const conditionsProfile = this._getProfile(this._profileConditionsIndex !== null ? this._profileConditionsIndex : settingsProfileIndex);
|
||||
if (conditionsProfile !== null) {
|
||||
void this._profileConditionsUI.prepare(settingsProfileIndex);
|
||||
}
|
||||
|
||||
// Update profile entries
|
||||
for (const entry of this._profileEntryList) {
|
||||
entry.cleanup();
|
||||
}
|
||||
this._profileEntryList = [];
|
||||
if (this._profileEntriesSupported) {
|
||||
for (let i = 0, ii = profiles.length; i < ii; ++i) {
|
||||
this._addProfileEntry(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onProfileActiveChange(e) {
|
||||
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
|
||||
const value = this._tryGetValidProfileIndex(element.value);
|
||||
if (value === null) { return; }
|
||||
void this.setDefaultProfile(value);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onAdd() {
|
||||
void this.duplicateProfile(this._settingsController.profileIndex);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onResetConfirm() {
|
||||
const modal = /** @type {import('./modal.js').Modal} */ (this._profileResetModal);
|
||||
modal.setVisible(false);
|
||||
const {node} = modal;
|
||||
const profileIndex = node.dataset.profileIndex;
|
||||
delete node.dataset.profileIndex;
|
||||
|
||||
const validProfileIndex = this._tryGetValidProfileIndex(profileIndex);
|
||||
if (validProfileIndex === null) { return; }
|
||||
|
||||
void this.resetProfile(validProfileIndex);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onDeleteConfirm() {
|
||||
const modal = /** @type {import('./modal.js').Modal} */ (this._profileRemoveModal);
|
||||
modal.setVisible(false);
|
||||
const {node} = modal;
|
||||
const profileIndex = node.dataset.profileIndex;
|
||||
delete node.dataset.profileIndex;
|
||||
|
||||
const validProfileIndex = this._tryGetValidProfileIndex(profileIndex);
|
||||
if (validProfileIndex === null) { return; }
|
||||
|
||||
void this.deleteProfile(validProfileIndex);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onCopyConfirm() {
|
||||
const modal = /** @type {import('./modal.js').Modal} */ (this._profileCopyModal);
|
||||
modal.setVisible(false);
|
||||
const {node} = modal;
|
||||
const destinationProfileIndex = node.dataset.profileIndex;
|
||||
delete node.dataset.profileIndex;
|
||||
|
||||
const validDestinationProfileIndex = this._tryGetValidProfileIndex(destinationProfileIndex);
|
||||
if (validDestinationProfileIndex === null) { return; }
|
||||
|
||||
const sourceProfileIndex = this._tryGetValidProfileIndex(/** @type {HTMLSelectElement} */ (this._profileCopySourceSelect).value);
|
||||
if (sourceProfileIndex === null) { return; }
|
||||
|
||||
void this.copyProfile(sourceProfileIndex, validDestinationProfileIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('profile-conditions-ui').EventArgument<'conditionGroupCountChanged'>} details
|
||||
*/
|
||||
_onConditionGroupCountChanged({count, profileIndex}) {
|
||||
if (profileIndex >= 0 && profileIndex < this._profileEntryList.length) {
|
||||
const profileEntry = this._profileEntryList[profileIndex];
|
||||
profileEntry.setConditionGroupsCount(count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
*/
|
||||
_addProfileEntry(profileIndex) {
|
||||
const profile = this._profiles[profileIndex];
|
||||
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('profile-entry'));
|
||||
const entry = new ProfileEntry(this, node, profile, profileIndex);
|
||||
this._profileEntryList.push(entry);
|
||||
entry.prepare();
|
||||
/** @type {HTMLElement} */ (this._profileEntryListContainer).appendChild(node);
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateProfileSelectOptions() {
|
||||
for (const select of this._getAllProfileSelects()) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < this._profiles.length; ++i) {
|
||||
const profile = this._profiles[i];
|
||||
const option = document.createElement('option');
|
||||
option.value = `${i}`;
|
||||
option.textContent = profile.name;
|
||||
fragment.appendChild(option);
|
||||
}
|
||||
select.textContent = '';
|
||||
select.appendChild(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @param {string} name
|
||||
*/
|
||||
_updateSelectName(index, name) {
|
||||
const optionValue = `${index}`;
|
||||
for (const select of this._getAllProfileSelects()) {
|
||||
for (const option of select.querySelectorAll('option')) {
|
||||
if (option.value === optionValue) {
|
||||
option.textContent = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLSelectElement[]}
|
||||
*/
|
||||
_getAllProfileSelects() {
|
||||
return [
|
||||
/** @type {HTMLSelectElement} */ (this._profileActiveSelect),
|
||||
/** @type {HTMLSelectElement} */ (this._profileCopySourceSelect),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} stringValue
|
||||
* @returns {?number}
|
||||
*/
|
||||
_tryGetValidProfileIndex(stringValue) {
|
||||
if (typeof stringValue !== 'string') { return null; }
|
||||
const intValue = Number.parseInt(stringValue, 10);
|
||||
return (
|
||||
Number.isFinite(intValue) &&
|
||||
intValue >= 0 &&
|
||||
intValue < this.profileCount ?
|
||||
intValue :
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {import('settings').Profile[]} profiles
|
||||
* @param {number} maxUniqueAttempts
|
||||
* @returns {string}
|
||||
*/
|
||||
_createCopyName(name, profiles, maxUniqueAttempts) {
|
||||
let space, index, prefix, suffix;
|
||||
const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name);
|
||||
if (match === null) {
|
||||
prefix = `${name} (Copy`;
|
||||
space = '';
|
||||
index = '';
|
||||
suffix = ')';
|
||||
} else {
|
||||
prefix = match[1];
|
||||
suffix = match[5];
|
||||
if (typeof match[2] === 'string') {
|
||||
space = match[3];
|
||||
index = Number.parseInt(match[4], 10) + 1;
|
||||
} else {
|
||||
space = ' ';
|
||||
index = 2;
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const newName = `${prefix}${space}${index}${suffix}`;
|
||||
if (i++ >= maxUniqueAttempts || !profiles.some((profile) => profile.name === newName)) {
|
||||
return newName;
|
||||
}
|
||||
if (typeof index !== 'number') {
|
||||
index = 2;
|
||||
space = ' ';
|
||||
} else {
|
||||
++index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {T} currentValue
|
||||
* @param {T} value1
|
||||
* @param {T} value2
|
||||
* @returns {T}
|
||||
*/
|
||||
_getSwappedValue(currentValue, value1, value2) {
|
||||
if (currentValue === value1) { return value2; }
|
||||
if (currentValue === value2) { return value1; }
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
* @returns {?import('settings').Profile}
|
||||
*/
|
||||
_getProfile(profileIndex) {
|
||||
return (profileIndex >= 0 && profileIndex < this._profiles.length ? this._profiles[profileIndex] : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} profileIndex
|
||||
* @returns {?ProfileEntry}
|
||||
*/
|
||||
_getProfileEntry(profileIndex) {
|
||||
return (profileIndex >= 0 && profileIndex < this._profileEntryList.length ? this._profileEntryList[profileIndex] : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} node1
|
||||
* @param {Element} node2
|
||||
*/
|
||||
_swapDomNodes(node1, node2) {
|
||||
const parent1 = node1.parentNode;
|
||||
const parent2 = node2.parentNode;
|
||||
const next1 = node1.nextSibling;
|
||||
const next2 = node2.nextSibling;
|
||||
if (node2 !== next1 && parent1 !== null) { parent1.insertBefore(node2, next1); }
|
||||
if (node1 !== next2 && parent2 !== null) { parent2.insertBefore(node1, next2); }
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileEntry {
|
||||
/**
|
||||
* @param {ProfileController} profileController
|
||||
* @param {HTMLElement} node
|
||||
* @param {import('settings').Profile} profile
|
||||
* @param {number} index
|
||||
*/
|
||||
constructor(profileController, node, profile, index) {
|
||||
/** @type {ProfileController} */
|
||||
this._profileController = profileController;
|
||||
/** @type {HTMLElement} */
|
||||
this._node = node;
|
||||
/** @type {import('settings').Profile} */
|
||||
this._profile = profile;
|
||||
/** @type {number} */
|
||||
this._index = index;
|
||||
/** @type {HTMLInputElement} */
|
||||
this._isDefaultRadio = querySelectorNotNull(node, '.profile-entry-is-default-radio');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._nameInput = querySelectorNotNull(node, '.profile-entry-name-input');
|
||||
/** @type {HTMLElement} */
|
||||
this._countLink = querySelectorNotNull(node, '.profile-entry-condition-count-link');
|
||||
/** @type {HTMLElement} */
|
||||
this._countText = querySelectorNotNull(node, '.profile-entry-condition-count');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._menuButton = querySelectorNotNull(node, '.profile-entry-menu-button');
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get index() {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
set index(value) {
|
||||
this._index = value;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get node() {
|
||||
return this._node;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this.updateState();
|
||||
|
||||
this._eventListeners.addEventListener(this._isDefaultRadio, 'change', this._onIsDefaultRadioChange.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._nameInput, 'input', this._onNameInputInput.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._countLink, 'click', this._onConditionsCountLinkClick.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._menuButton, 'menuClose', this._onMenuClose.bind(this), false);
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
if (this._node.parentNode !== null) {
|
||||
this._node.parentNode.removeChild(this._node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
setName(value) {
|
||||
if (this._nameInput.value === value) { return; }
|
||||
this._nameInput.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
*/
|
||||
setIsDefault(value) {
|
||||
this._isDefaultRadio.checked = value;
|
||||
}
|
||||
|
||||
/** */
|
||||
updateState() {
|
||||
this._nameInput.value = this._profile.name;
|
||||
this._countText.textContent = `${this._profile.conditionGroups.length}`;
|
||||
this._isDefaultRadio.checked = (this._index === this._profileController.profileCurrentIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} count
|
||||
*/
|
||||
setConditionGroupsCount(count) {
|
||||
this._countText.textContent = `${count}`;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onIsDefaultRadioChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
if (!element.checked) { return; }
|
||||
void this._profileController.setDefaultProfile(this._index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onNameInputInput(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const name = element.value;
|
||||
void this._profileController.setProfileName(this._index, name);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onConditionsCountLinkClick() {
|
||||
this._profileController.openProfileConditionsModal(this._index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuOpenEvent} e
|
||||
*/
|
||||
_onMenuOpen(e) {
|
||||
const bodyNode = e.detail.menu.bodyNode;
|
||||
const count = this._profileController.profileCount;
|
||||
this._setMenuActionEnabled(bodyNode, 'moveUp', this._index > 0);
|
||||
this._setMenuActionEnabled(bodyNode, 'moveDown', this._index < count - 1);
|
||||
this._setMenuActionEnabled(bodyNode, 'copyFrom', count > 1);
|
||||
this._setMenuActionEnabled(bodyNode, 'delete', count > 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onMenuClose(e) {
|
||||
switch (e.detail.action) {
|
||||
case 'moveUp':
|
||||
void this._profileController.moveProfile(this._index, -1);
|
||||
break;
|
||||
case 'moveDown':
|
||||
void this._profileController.moveProfile(this._index, 1);
|
||||
break;
|
||||
case 'copyFrom':
|
||||
this._profileController.openCopyProfileModal(this._index);
|
||||
break;
|
||||
case 'editConditions':
|
||||
this._profileController.openProfileConditionsModal(this._index);
|
||||
break;
|
||||
case 'duplicate':
|
||||
void this._profileController.duplicateProfile(this._index);
|
||||
break;
|
||||
case 'reset':
|
||||
this._profileController.openResetProfileModal(this._index);
|
||||
break;
|
||||
case 'delete':
|
||||
this._profileController.openDeleteProfileModal(this._index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} menu
|
||||
* @param {string} action
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
_setMenuActionEnabled(menu, action, enabled) {
|
||||
const element = /** @type {HTMLButtonElement} */ (menu.querySelector(`[data-menu-action="${action}"]`));
|
||||
if (element === null) { return; }
|
||||
element.disabled = !enabled;
|
||||
}
|
||||
}
|
||||
141
vendor/yomitan/js/pages/settings/recommended-permissions-controller.js
vendored
Normal file
141
vendor/yomitan/js/pages/settings/recommended-permissions-controller.js
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {getAllPermissions, setPermissionsGranted} from '../../data/permissions-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class RecommendedPermissionsController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {HTMLInputElement} */
|
||||
this._originToggleNode = querySelectorNotNull(document, '#recommended-permissions-toggle');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._optionalPermissionsToggleNode = querySelectorNotNull(document, '#optional-permissions-toggle');
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {?HTMLElement} */
|
||||
this._errorContainer = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._errorContainer = document.querySelector('#recommended-permissions-error');
|
||||
this._originToggleNode.addEventListener('change', this._onOriginToggleChange.bind(this), false);
|
||||
this._optionalPermissionsToggleNode.addEventListener('change', this._onOptionalPermissionsToggleChange.bind(this), false);
|
||||
|
||||
this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this));
|
||||
await this._updatePermissions();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'permissionsChanged'>} details
|
||||
*/
|
||||
_onPermissionsChanged({permissions}) {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
const originsSet = new Set(permissions.origins);
|
||||
const {origin} = this._originToggleNode.dataset;
|
||||
this._originToggleNode.checked = typeof origin === 'string' && originsSet.has(origin);
|
||||
|
||||
this._optionalPermissionsToggleNode.checked = Array.isArray(permissions.permissions) && permissions.permissions.includes('clipboardRead') && permissions.permissions.includes('nativeMessaging');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onOriginToggleChange(e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const value = node.checked;
|
||||
node.checked = !value;
|
||||
|
||||
const {origin} = node.dataset;
|
||||
if (typeof origin !== 'string') { return; }
|
||||
void this._setOriginPermissionEnabled(origin, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
async _onOptionalPermissionsToggleChange(e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const value = node.checked;
|
||||
const permissions = ['clipboardRead', 'nativeMessaging'];
|
||||
await setPermissionsGranted({permissions}, value);
|
||||
await this._updatePermissions();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updatePermissions() {
|
||||
const permissions = await getAllPermissions();
|
||||
this._onPermissionsChanged({permissions});
|
||||
void this._setWelcomePageText();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} origin
|
||||
* @param {boolean} enabled
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _setOriginPermissionEnabled(origin, enabled) {
|
||||
let added = false;
|
||||
try {
|
||||
added = await setPermissionsGranted({origins: [origin]}, enabled);
|
||||
} catch (e) {
|
||||
if (this._errorContainer !== null) {
|
||||
this._errorContainer.hidden = false;
|
||||
this._errorContainer.textContent = toError(e).message;
|
||||
}
|
||||
}
|
||||
await this._updatePermissions();
|
||||
return added;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _setWelcomePageText() {
|
||||
const permissions = await getAllPermissions();
|
||||
const recommendedPermissions = permissions.origins?.includes('<all_urls>');
|
||||
const optionalPermissions = permissions.permissions?.includes('clipboardRead') && permissions.permissions?.includes('nativeMessaging');
|
||||
/** @type {HTMLElement | null} */
|
||||
this._textIfFullEnabled = document.querySelector('#full-permissions-enabled');
|
||||
/** @type {HTMLElement | null} */
|
||||
this._textIfRecommendedEnabled = document.querySelector('#recommended-permissions-enabled');
|
||||
/** @type {HTMLElement | null} */
|
||||
this._textIfDisabled = document.querySelector('#permissions-disabled');
|
||||
|
||||
if (this._textIfFullEnabled && this._textIfRecommendedEnabled && this._textIfDisabled) {
|
||||
this._textIfFullEnabled.hidden = true;
|
||||
this._textIfRecommendedEnabled.hidden = true;
|
||||
this._textIfDisabled.hidden = true;
|
||||
|
||||
if (optionalPermissions && recommendedPermissions) {
|
||||
this._textIfFullEnabled.hidden = false;
|
||||
} else if (recommendedPermissions) {
|
||||
this._textIfRecommendedEnabled.hidden = false;
|
||||
} else {
|
||||
this._textIfDisabled.hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
vendor/yomitan/js/pages/settings/recommended-settings-controller.js
vendored
Normal file
194
vendor/yomitan/js/pages/settings/recommended-settings-controller.js
vendored
Normal file
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Yomitan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {fetchJson} from '../../core/fetch-utilities.js';
|
||||
import {log} from '../../core/log.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class RecommendedSettingsController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {HTMLElement} */
|
||||
this._recommendedSettingsModal = querySelectorNotNull(document, '#recommended-settings-modal');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._languageSelect = querySelectorNotNull(document, '#language-select');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._applyButton = querySelectorNotNull(document, '#recommended-settings-apply-button');
|
||||
/** @type {Map<string, import('settings-controller').RecommendedSetting>} */
|
||||
this._recommendedSettings = new Map();
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._languageSelect.addEventListener('change', this._onLanguageSelectChanged.bind(this), false);
|
||||
this._applyButton.addEventListener('click', this._onApplyButtonClicked.bind(this), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} _e
|
||||
*/
|
||||
async _onLanguageSelectChanged(_e) {
|
||||
const setLanguage = this._languageSelect.value;
|
||||
if (typeof setLanguage !== 'string') { return; }
|
||||
|
||||
const recommendedSettings = await this._getRecommendedSettings(setLanguage);
|
||||
if (typeof recommendedSettings !== 'undefined') {
|
||||
const settingsList = querySelectorNotNull(document, '#recommended-settings-list');
|
||||
settingsList.innerHTML = '';
|
||||
this._recommendedSettings = new Map();
|
||||
|
||||
for (const [index, setting] of recommendedSettings.entries()) {
|
||||
this._recommendedSettings.set(index.toString(), setting);
|
||||
|
||||
const {description} = setting;
|
||||
const template = this._settingsController.instantiateTemplate('recommended-settings-list-item');
|
||||
|
||||
// Render label
|
||||
this._renderLabel(template, setting);
|
||||
|
||||
// Render description
|
||||
const descriptionElement = querySelectorNotNull(template, '.settings-item-description');
|
||||
if (description !== 'undefined') {
|
||||
descriptionElement.textContent = description;
|
||||
}
|
||||
|
||||
// Render checkbox
|
||||
const checkbox = /** @type {HTMLInputElement} */ (querySelectorNotNull(template, 'input[type="checkbox"]'));
|
||||
checkbox.value = index.toString();
|
||||
|
||||
settingsList.append(template);
|
||||
}
|
||||
this._recommendedSettingsModal.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} language
|
||||
* @returns {Promise<import('settings-controller').RecommendedSetting[]>}
|
||||
*/
|
||||
async _getRecommendedSettings(language) {
|
||||
if (typeof this._recommendedSettingsByLanguage === 'undefined') {
|
||||
/** @type {import('settings-controller').RecommendedSettingsByLanguage} */
|
||||
this._recommendedSettingsByLanguage = await fetchJson('/data/recommended-settings.json');
|
||||
}
|
||||
|
||||
return this._recommendedSettingsByLanguage[language];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onApplyButtonClicked(e) {
|
||||
e.preventDefault();
|
||||
/** @type {NodeListOf<HTMLInputElement>} */
|
||||
const enabledCheckboxes = querySelectorNotNull(document, '#recommended-settings-list').querySelectorAll('input[type="checkbox"]:checked');
|
||||
if (enabledCheckboxes.length > 0) {
|
||||
const modifications = [];
|
||||
for (const checkbox of enabledCheckboxes) {
|
||||
const index = checkbox.value;
|
||||
const setting = this._recommendedSettings.get(index);
|
||||
if (typeof setting === 'undefined') { continue; }
|
||||
modifications.push(setting.modification);
|
||||
}
|
||||
void this._settingsController.modifyProfileSettings(modifications).then(
|
||||
(results) => {
|
||||
for (const result of results) {
|
||||
if (Object.hasOwn(result, 'error')) {
|
||||
log.error(new Error(`Failed to apply recommended setting: ${JSON.stringify(result)}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
void this._settingsController.refresh();
|
||||
}
|
||||
this._recommendedSettingsModal.hidden = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} template
|
||||
* @param {import('settings-controller').RecommendedSetting} setting
|
||||
*/
|
||||
_renderLabel(template, setting) {
|
||||
const label = querySelectorNotNull(template, '.settings-item-label');
|
||||
|
||||
const {modification} = setting;
|
||||
switch (modification.action) {
|
||||
case 'set': {
|
||||
const {path, value} = modification;
|
||||
const pathCodeElement = document.createElement('code');
|
||||
pathCodeElement.textContent = path;
|
||||
const valueCodeElement = document.createElement('code');
|
||||
valueCodeElement.textContent = JSON.stringify(value, null, 2);
|
||||
|
||||
label.appendChild(document.createTextNode('Setting '));
|
||||
label.appendChild(pathCodeElement);
|
||||
label.appendChild(document.createTextNode(' = '));
|
||||
label.appendChild(valueCodeElement);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const {path} = modification;
|
||||
const pathCodeElement = document.createElement('code');
|
||||
pathCodeElement.textContent = path;
|
||||
|
||||
label.appendChild(document.createTextNode('Deleting '));
|
||||
label.appendChild(pathCodeElement);
|
||||
break;
|
||||
}
|
||||
case 'swap': {
|
||||
const {path1, path2} = modification;
|
||||
const path1CodeElement = document.createElement('code');
|
||||
path1CodeElement.textContent = path1;
|
||||
const path2CodeElement = document.createElement('code');
|
||||
path2CodeElement.textContent = path2;
|
||||
|
||||
label.appendChild(document.createTextNode('Swapping '));
|
||||
label.appendChild(path1CodeElement);
|
||||
label.appendChild(document.createTextNode(' and '));
|
||||
label.appendChild(path2CodeElement);
|
||||
break;
|
||||
}
|
||||
case 'splice': {
|
||||
const {path, start, deleteCount, items} = modification;
|
||||
const pathCodeElement = document.createElement('code');
|
||||
pathCodeElement.textContent = path;
|
||||
|
||||
label.appendChild(document.createTextNode('Splicing '));
|
||||
label.appendChild(pathCodeElement);
|
||||
label.appendChild(document.createTextNode(` at ${start} deleting ${deleteCount} items and inserting ${items.length} items`));
|
||||
break;
|
||||
}
|
||||
case 'push': {
|
||||
const {path, items} = modification;
|
||||
const pathCodeElement = document.createElement('code');
|
||||
pathCodeElement.textContent = path;
|
||||
|
||||
label.appendChild(document.createTextNode(`Pushing ${items.length} items to `));
|
||||
label.appendChild(pathCodeElement);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
log.error(new Error(`Unknown modification: ${modification}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
440
vendor/yomitan/js/pages/settings/scan-inputs-controller.js
vendored
Normal file
440
vendor/yomitan/js/pages/settings/scan-inputs-controller.js
vendored
Normal file
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {normalizeModifier} from '../../dom/document-util.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js';
|
||||
|
||||
export class ScanInputsController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {?import('environment').OperatingSystem} */
|
||||
this._os = null;
|
||||
/** @type {HTMLElement} */
|
||||
this._container = querySelectorNotNull(document, '#scan-input-list');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._addButton = querySelectorNotNull(document, '#scan-input-add');
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._scanningInputCountNodes = null;
|
||||
/** @type {ScanInputField[]} */
|
||||
this._entries = [];
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
|
||||
this._os = os;
|
||||
|
||||
this._scanningInputCountNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.scanning-input-count'));
|
||||
|
||||
this._addButton.addEventListener('click', this._onAddButtonClick.bind(this), false);
|
||||
this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {boolean}
|
||||
*/
|
||||
removeInput(index) {
|
||||
if (index < 0 || index >= this._entries.length) { return false; }
|
||||
const input = this._entries[index];
|
||||
input.cleanup();
|
||||
this._entries.splice(index, 1);
|
||||
for (let i = index, ii = this._entries.length; i < ii; ++i) {
|
||||
this._entries[i].index = i;
|
||||
}
|
||||
this._updateCounts();
|
||||
void this._modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'scanning.inputs',
|
||||
start: index,
|
||||
deleteCount: 1,
|
||||
items: [],
|
||||
}]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @param {string} property
|
||||
* @param {unknown} value
|
||||
* @param {boolean} event
|
||||
*/
|
||||
async setProperty(index, property, value, event) {
|
||||
const path = `scanning.inputs[${index}].${property}`;
|
||||
await this._settingsController.setProfileSetting(path, value);
|
||||
if (event) {
|
||||
this._triggerScanInputsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Element}
|
||||
*/
|
||||
instantiateTemplate(name) {
|
||||
return this._settingsController.instantiateTemplate(name);
|
||||
}
|
||||
|
||||
/** */
|
||||
async refresh() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'scanInputsChanged'>} details
|
||||
*/
|
||||
_onScanInputsChanged({source}) {
|
||||
if (source === this) { return; }
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
const {inputs} = options.scanning;
|
||||
|
||||
for (let i = this._entries.length - 1; i >= 0; --i) {
|
||||
this._entries[i].cleanup();
|
||||
}
|
||||
this._entries.length = 0;
|
||||
|
||||
for (let i = 0, ii = inputs.length; i < ii; ++i) {
|
||||
this._addOption(i, inputs[i]);
|
||||
}
|
||||
|
||||
this._updateCounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onAddButtonClick(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const index = this._entries.length;
|
||||
const scanningInput = ScanInputsController.createDefaultMouseInput('', '');
|
||||
this._addOption(index, scanningInput);
|
||||
this._updateCounts();
|
||||
void this._modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'scanning.inputs',
|
||||
start: index,
|
||||
deleteCount: 0,
|
||||
items: [scanningInput],
|
||||
}]);
|
||||
|
||||
// Scroll to bottom
|
||||
const button = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const modalContainer = /** @type {HTMLElement} */ (button.closest('.modal'));
|
||||
/** @type {HTMLElement} */
|
||||
const scrollContainer = querySelectorNotNull(modalContainer, '.modal-body');
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @param {import('settings').ScanningInput} scanningInput
|
||||
*/
|
||||
_addOption(index, scanningInput) {
|
||||
if (this._os === null || this._container === null) { return; }
|
||||
const field = new ScanInputField(this, index, this._os);
|
||||
this._entries.push(field);
|
||||
field.prepare(this._container, scanningInput);
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateCounts() {
|
||||
const stringValue = `${this._entries.length}`;
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._scanningInputCountNodes)) {
|
||||
node.textContent = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
*/
|
||||
async _modifyProfileSettings(targets) {
|
||||
await this._settingsController.modifyProfileSettings(targets);
|
||||
this._triggerScanInputsChanged();
|
||||
}
|
||||
|
||||
/** */
|
||||
_triggerScanInputsChanged() {
|
||||
/** @type {import('settings-controller').EventArgument<'scanInputsChanged'>} */
|
||||
const event = {source: this};
|
||||
this._settingsController.trigger('scanInputsChanged', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} include
|
||||
* @param {string} exclude
|
||||
* @returns {import('settings').ScanningInput}
|
||||
*/
|
||||
static createDefaultMouseInput(include, exclude) {
|
||||
return {
|
||||
include,
|
||||
exclude,
|
||||
types: {mouse: true, touch: false, pen: false},
|
||||
options: {
|
||||
showAdvanced: false,
|
||||
searchTerms: true,
|
||||
searchKanji: true,
|
||||
scanOnTouchTap: true,
|
||||
scanOnTouchMove: false,
|
||||
scanOnTouchPress: false,
|
||||
scanOnTouchRelease: false,
|
||||
scanOnPenMove: true,
|
||||
scanOnPenHover: false,
|
||||
scanOnPenReleaseHover: false,
|
||||
scanOnPenPress: true,
|
||||
scanOnPenRelease: false,
|
||||
preventTouchScrolling: true,
|
||||
preventPenScrolling: true,
|
||||
minimumTouchTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ScanInputField {
|
||||
/**
|
||||
* @param {ScanInputsController} parent
|
||||
* @param {number} index
|
||||
* @param {import('environment').OperatingSystem} os
|
||||
*/
|
||||
constructor(parent, index, os) {
|
||||
/** @type {ScanInputsController} */
|
||||
this._parent = parent;
|
||||
/** @type {number} */
|
||||
this._index = index;
|
||||
/** @type {import('environment').OperatingSystem} */
|
||||
this._os = os;
|
||||
/** @type {?HTMLElement} */
|
||||
this._node = null;
|
||||
/** @type {?KeyboardMouseInputField} */
|
||||
this._includeInputField = null;
|
||||
/** @type {?KeyboardMouseInputField} */
|
||||
this._excludeInputField = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get index() {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
set index(value) {
|
||||
this._index = value;
|
||||
this._updateDataSettingTargets();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {import('settings').ScanningInput} scanningInput
|
||||
*/
|
||||
prepare(container, scanningInput) {
|
||||
const {include, exclude, options: {showAdvanced}} = scanningInput;
|
||||
|
||||
const node = /** @type {HTMLElement} */ (this._parent.instantiateTemplate('scan-input'));
|
||||
/** @type {HTMLInputElement} */
|
||||
const includeInputNode = querySelectorNotNull(node, '.scan-input-field[data-property=include]');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const includeMouseButton = querySelectorNotNull(node, '.mouse-button[data-property=include]');
|
||||
/** @type {HTMLInputElement} */
|
||||
const excludeInputNode = querySelectorNotNull(node, '.scan-input-field[data-property=exclude]');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const excludeMouseButton = querySelectorNotNull(node, '.mouse-button[data-property=exclude]');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const menuButton = querySelectorNotNull(node, '.scanning-input-menu-button');
|
||||
|
||||
node.dataset.showAdvanced = `${showAdvanced}`;
|
||||
|
||||
this._node = node;
|
||||
container.appendChild(node);
|
||||
|
||||
const isPointerTypeSupported = this._isPointerTypeSupported.bind(this);
|
||||
this._includeInputField = new KeyboardMouseInputField(includeInputNode, includeMouseButton, this._os, isPointerTypeSupported);
|
||||
this._excludeInputField = new KeyboardMouseInputField(excludeInputNode, excludeMouseButton, this._os, isPointerTypeSupported);
|
||||
this._includeInputField.prepare(null, this._splitModifiers(include), true, false);
|
||||
this._excludeInputField.prepare(null, this._splitModifiers(exclude), true, false);
|
||||
|
||||
this._eventListeners.on(this._includeInputField, 'change', this._onIncludeValueChange.bind(this));
|
||||
this._eventListeners.on(this._excludeInputField, 'change', this._onExcludeValueChange.bind(this));
|
||||
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this));
|
||||
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this));
|
||||
|
||||
this._updateDataSettingTargets();
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
if (this._includeInputField !== null) {
|
||||
this._includeInputField.cleanup();
|
||||
this._includeInputField = null;
|
||||
}
|
||||
if (this._node !== null) {
|
||||
const parent = this._node.parentNode;
|
||||
if (parent !== null) { parent.removeChild(this._node); }
|
||||
this._node = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} details
|
||||
*/
|
||||
_onIncludeValueChange({modifiers}) {
|
||||
const modifiers2 = this._joinModifiers(modifiers);
|
||||
void this._parent.setProperty(this._index, 'include', modifiers2, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} details
|
||||
*/
|
||||
_onExcludeValueChange({modifiers}) {
|
||||
const modifiers2 = this._joinModifiers(modifiers);
|
||||
void this._parent.setProperty(this._index, 'exclude', modifiers2, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onRemoveClick(e) {
|
||||
e.preventDefault();
|
||||
this._removeSelf();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuOpenEvent} e
|
||||
*/
|
||||
_onMenuOpen(e) {
|
||||
const bodyNode = e.detail.menu.bodyNode;
|
||||
/** @type {?HTMLElement} */
|
||||
const showAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="showAdvanced"]');
|
||||
/** @type {?HTMLElement} */
|
||||
const hideAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="hideAdvanced"]');
|
||||
const advancedVisible = (this._node !== null && this._node.dataset.showAdvanced === 'true');
|
||||
if (showAdvanced !== null) {
|
||||
showAdvanced.hidden = advancedVisible;
|
||||
}
|
||||
if (hideAdvanced !== null) {
|
||||
hideAdvanced.hidden = !advancedVisible;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onMenuClose(e) {
|
||||
switch (e.detail.action) {
|
||||
case 'remove':
|
||||
this._removeSelf();
|
||||
break;
|
||||
case 'showAdvanced':
|
||||
this._setAdvancedOptionsVisible(true);
|
||||
break;
|
||||
case 'hideAdvanced':
|
||||
this._setAdvancedOptionsVisible(false);
|
||||
break;
|
||||
case 'clearInputs':
|
||||
/** @type {KeyboardMouseInputField} */ (this._includeInputField).clearInputs();
|
||||
/** @type {KeyboardMouseInputField} */ (this._excludeInputField).clearInputs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pointerType
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isPointerTypeSupported(pointerType) {
|
||||
if (this._node === null) { return false; }
|
||||
const node = /** @type {?HTMLInputElement} */ (this._node.querySelector(`input.scan-input-settings-checkbox[data-property="types.${pointerType}"]`));
|
||||
return node !== null && node.checked;
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateDataSettingTargets() {
|
||||
if (this._node === null) { return; }
|
||||
const index = this._index;
|
||||
for (const typeCheckbox of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.scan-input-settings-checkbox'))) {
|
||||
const {property} = typeCheckbox.dataset;
|
||||
typeCheckbox.dataset.setting = `scanning.inputs[${index}].${property}`;
|
||||
}
|
||||
for (const typeInput of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.scan-input-settings-input'))) {
|
||||
const {property} = typeInput.dataset;
|
||||
typeInput.dataset.setting = `scanning.inputs[${index}].${property}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_removeSelf() {
|
||||
this._parent.removeInput(this._index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} showAdvanced
|
||||
*/
|
||||
_setAdvancedOptionsVisible(showAdvanced) {
|
||||
showAdvanced = !!showAdvanced;
|
||||
if (this._node !== null) {
|
||||
this._node.dataset.showAdvanced = `${showAdvanced}`;
|
||||
}
|
||||
void this._parent.setProperty(this._index, 'options.showAdvanced', showAdvanced, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modifiersString
|
||||
* @returns {import('input').Modifier[]}
|
||||
*/
|
||||
_splitModifiers(modifiersString) {
|
||||
/** @type {import('input').Modifier[]} */
|
||||
const results = [];
|
||||
for (const modifier of modifiersString.split(/[,;\s]+/)) {
|
||||
const modifier2 = normalizeModifier(modifier.trim().toLowerCase());
|
||||
if (modifier2 === null) { continue; }
|
||||
results.push(modifier2);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('input').Modifier[]} modifiersArray
|
||||
* @returns {string}
|
||||
*/
|
||||
_joinModifiers(modifiersArray) {
|
||||
return modifiersArray.join(', ');
|
||||
}
|
||||
}
|
||||
305
vendor/yomitan/js/pages/settings/scan-inputs-simple-controller.js
vendored
Normal file
305
vendor/yomitan/js/pages/settings/scan-inputs-simple-controller.js
vendored
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {HotkeyUtil} from '../../input/hotkey-util.js';
|
||||
import {ScanInputsController} from './scan-inputs-controller.js';
|
||||
|
||||
export class ScanInputsSimpleController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {HTMLInputElement} */
|
||||
this._middleMouseButtonScan = querySelectorNotNull(document, '#middle-mouse-button-scan');
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._mainScanModifierKeyInput = querySelectorNotNull(document, '#main-scan-modifier-key');
|
||||
/** @type {boolean} */
|
||||
this._mainScanModifierKeyInputHasOther = false;
|
||||
/** @type {HotkeyUtil} */
|
||||
this._hotkeyUtil = new HotkeyUtil();
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
|
||||
this._hotkeyUtil.os = os;
|
||||
|
||||
this._mainScanModifierKeyInputHasOther = false;
|
||||
this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther);
|
||||
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
|
||||
this._middleMouseButtonScan.addEventListener('change', this.onMiddleMouseButtonScanChange.bind(this), false);
|
||||
this._mainScanModifierKeyInput.addEventListener('change', this._onMainScanModifierKeyInputChange.bind(this), false);
|
||||
|
||||
this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/** */
|
||||
async refresh() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'scanInputsChanged'>} details
|
||||
*/
|
||||
_onScanInputsChanged({source}) {
|
||||
if (source === this) { return; }
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
const {scanning: {inputs}} = options;
|
||||
const middleMouseSupportedIndex = this._getIndexOfMiddleMouseButtonScanInput(inputs);
|
||||
const mainScanInputIndex = this._getIndexOfMainScanInput(inputs);
|
||||
const hasMainScanInput = (mainScanInputIndex >= 0);
|
||||
|
||||
let middleMouseSupported = false;
|
||||
if (middleMouseSupportedIndex >= 0) {
|
||||
const includeValues = this._splitValue(inputs[middleMouseSupportedIndex].include);
|
||||
if (includeValues.includes('mouse2')) {
|
||||
middleMouseSupported = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mainScanInput = 'none';
|
||||
if (hasMainScanInput) {
|
||||
const includeValues = this._splitValue(inputs[mainScanInputIndex].include);
|
||||
if (includeValues.length > 0) {
|
||||
mainScanInput = includeValues[0];
|
||||
}
|
||||
} else {
|
||||
mainScanInput = 'other';
|
||||
}
|
||||
|
||||
this._setHasMainScanInput(hasMainScanInput);
|
||||
|
||||
/** @type {HTMLInputElement} */ (this._middleMouseButtonScan).checked = middleMouseSupported;
|
||||
/** @type {HTMLSelectElement} */ (this._mainScanModifierKeyInput).value = mainScanInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
onMiddleMouseButtonScanChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const middleMouseSupported = element.checked;
|
||||
void this._setMiddleMouseSuppported(middleMouseSupported);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onMainScanModifierKeyInputChange(e) {
|
||||
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
|
||||
const mainScanKey = element.value;
|
||||
if (mainScanKey === 'other') { return; }
|
||||
const mainScanInputs = (mainScanKey === 'none' ? [] : [mainScanKey]);
|
||||
void this._setMainScanInputs(mainScanInputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLSelectElement} select
|
||||
* @param {boolean} hasOther
|
||||
*/
|
||||
_populateSelect(select, hasOther) {
|
||||
const modifierKeys = [
|
||||
{value: 'none', name: 'No key'},
|
||||
];
|
||||
for (const value of /** @type {import('input').ModifierKey[]} */ (['alt', 'ctrl', 'shift', 'meta'])) {
|
||||
const name = this._hotkeyUtil.getModifierDisplayValue(value);
|
||||
modifierKeys.push({value, name});
|
||||
}
|
||||
|
||||
if (hasOther) {
|
||||
modifierKeys.push({value: 'other', name: 'Other'});
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const {value, name} of modifierKeys) {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = name;
|
||||
fragment.appendChild(option);
|
||||
}
|
||||
select.textContent = '';
|
||||
select.appendChild(fragment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_splitValue(value) {
|
||||
return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
*/
|
||||
async _setMiddleMouseSuppported(value) {
|
||||
// Find target index
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {scanning: {inputs}} = options;
|
||||
const index = this._getIndexOfMiddleMouseButtonScanInput(inputs);
|
||||
|
||||
if (value) {
|
||||
// Add new
|
||||
if (index >= 0) { return; }
|
||||
let insertionPosition = this._getIndexOfMainScanInput(inputs);
|
||||
insertionPosition = (insertionPosition >= 0 ? insertionPosition + 1 : inputs.length);
|
||||
const input = ScanInputsController.createDefaultMouseInput('mouse2', '');
|
||||
await this._modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'scanning.inputs',
|
||||
start: insertionPosition,
|
||||
deleteCount: 0,
|
||||
items: [input],
|
||||
}]);
|
||||
} else {
|
||||
// Modify existing
|
||||
if (index < 0) { return; }
|
||||
await this._modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'scanning.inputs',
|
||||
start: index,
|
||||
deleteCount: 1,
|
||||
items: [],
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} value
|
||||
*/
|
||||
async _setMainScanInputs(value) {
|
||||
const value2 = value.join(', ');
|
||||
|
||||
// Find target index
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {scanning: {inputs}} = options;
|
||||
const index = this._getIndexOfMainScanInput(inputs);
|
||||
|
||||
this._setHasMainScanInput(true);
|
||||
|
||||
if (index < 0) {
|
||||
// Add new
|
||||
const input = ScanInputsController.createDefaultMouseInput(value2, 'mouse0');
|
||||
await this._modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'scanning.inputs',
|
||||
start: inputs.length,
|
||||
deleteCount: 0,
|
||||
items: [input],
|
||||
}]);
|
||||
} else {
|
||||
// Modify existing
|
||||
await this._modifyProfileSettings([{
|
||||
action: 'set',
|
||||
path: `scanning.inputs[${index}].include`,
|
||||
value: value2,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
*/
|
||||
async _modifyProfileSettings(targets) {
|
||||
await this._settingsController.modifyProfileSettings(targets);
|
||||
/** @type {import('settings-controller').EventArgument<'scanInputsChanged'>} */
|
||||
const event = {source: this};
|
||||
this._settingsController.trigger('scanInputsChanged', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ScanningInput[]} inputs
|
||||
* @returns {number}
|
||||
*/
|
||||
_getIndexOfMainScanInput(inputs) {
|
||||
for (let i = 0, ii = inputs.length; i < ii; ++i) {
|
||||
const {include, exclude, types: {mouse}} = inputs[i];
|
||||
if (!mouse) { continue; }
|
||||
const includeValues = this._splitValue(include);
|
||||
const excludeValues = this._splitValue(exclude);
|
||||
if (
|
||||
(
|
||||
includeValues.length === 0 ||
|
||||
(includeValues.length === 1 && !this._isMouseInput(includeValues[0]))
|
||||
) &&
|
||||
excludeValues.length === 1 &&
|
||||
excludeValues[0] === 'mouse0'
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ScanningInput[]} inputs
|
||||
* @returns {number}
|
||||
*/
|
||||
_getIndexOfMiddleMouseButtonScanInput(inputs) {
|
||||
for (let i = 0, ii = inputs.length; i < ii; ++i) {
|
||||
const {include, exclude, types: {mouse}} = inputs[i];
|
||||
if (!mouse) { continue; }
|
||||
const includeValues = this._splitValue(include);
|
||||
const excludeValues = this._splitValue(exclude);
|
||||
if (
|
||||
(includeValues.length === 1 && includeValues[0] === 'mouse2') &&
|
||||
excludeValues.length === 0
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} input
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isMouseInput(input) {
|
||||
return /^mouse\d+$/.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} hasMainScanInput
|
||||
*/
|
||||
_setHasMainScanInput(hasMainScanInput) {
|
||||
if (this._mainScanModifierKeyInputHasOther !== hasMainScanInput) { return; }
|
||||
this._mainScanModifierKeyInputHasOther = !hasMainScanInput;
|
||||
if (this._mainScanModifierKeyInput !== null) {
|
||||
this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther);
|
||||
}
|
||||
}
|
||||
}
|
||||
118
vendor/yomitan/js/pages/settings/secondary-search-dictionary-controller.js
vendored
Normal file
118
vendor/yomitan/js/pages/settings/secondary-search-dictionary-controller.js
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class SecondarySearchDictionaryController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._getDictionaryInfoToken = null;
|
||||
/** @type {Map<string, import('dictionary-importer').Summary>} */
|
||||
this._dictionaryInfoMap = new Map();
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {HTMLElement} */
|
||||
this._container = querySelectorNotNull(document, '#secondary-search-dictionary-list');
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
await this._onDatabaseUpdated();
|
||||
|
||||
this._settingsController.application.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
this._settingsController.on('dictionarySettingsReordered', this._onDictionarySettingsReordered.bind(this));
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _onDatabaseUpdated() {
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._getDictionaryInfoToken = token;
|
||||
const dictionaries = await this._settingsController.getDictionaryInfo();
|
||||
if (this._getDictionaryInfoToken !== token) { return; }
|
||||
this._getDictionaryInfoToken = null;
|
||||
|
||||
this._dictionaryInfoMap.clear();
|
||||
for (const entry of dictionaries) {
|
||||
this._dictionaryInfoMap.set(entry.title, entry);
|
||||
}
|
||||
|
||||
await this._onDictionarySettingsReordered();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const {dictionaries} = options;
|
||||
for (let i = 0, ii = dictionaries.length; i < ii; ++i) {
|
||||
const {name} = dictionaries[i];
|
||||
const dictionaryInfo = this._dictionaryInfoMap.get(name);
|
||||
if (typeof dictionaryInfo === 'undefined') { continue; }
|
||||
|
||||
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('secondary-search-dictionary'));
|
||||
fragment.appendChild(node);
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const nameNode = querySelectorNotNull(node, '.dictionary-title');
|
||||
nameNode.textContent = name;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const versionNode = querySelectorNotNull(node, '.dictionary-revision');
|
||||
versionNode.textContent = `rev.${dictionaryInfo.revision}`;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const toggle = querySelectorNotNull(node, '.dictionary-allow-secondary-searches');
|
||||
toggle.dataset.setting = `dictionaries[${i}].allowSecondarySearches`;
|
||||
this._eventListeners.addEventListener(toggle, 'settingChanged', this._onEnabledChanged.bind(this, node), false);
|
||||
}
|
||||
|
||||
const container = /** @type {HTMLElement} */ (this._container);
|
||||
container.textContent = '';
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
* @param {import('dom-data-binder').SettingChangedEvent} e
|
||||
*/
|
||||
_onEnabledChanged(node, e) {
|
||||
const {detail: {value}} = e;
|
||||
node.dataset.enabled = `${value}`;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onDictionarySettingsReordered() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
}
|
||||
328
vendor/yomitan/js/pages/settings/sentence-termination-characters-controller.js
vendored
Normal file
328
vendor/yomitan/js/pages/settings/sentence-termination-characters-controller.js
vendored
Normal file
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class SentenceTerminationCharactersController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {SentenceTerminationCharacterEntry[]} */
|
||||
this._entries = [];
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._addButton = querySelectorNotNull(document, '#sentence-termination-character-list-add');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._resetButton = querySelectorNotNull(document, '#sentence-termination-character-list-reset');
|
||||
/** @type {HTMLElement} */
|
||||
this._listTable = querySelectorNotNull(document, '#sentence-termination-character-list-table');
|
||||
/** @type {HTMLElement} */
|
||||
this._listContainer = querySelectorNotNull(document, '#sentence-termination-character-list');
|
||||
/** @type {HTMLElement} */
|
||||
this._emptyIndicator = querySelectorNotNull(document, '#sentence-termination-character-list-empty');
|
||||
}
|
||||
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
get settingsController() {
|
||||
return this._settingsController;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._addButton.addEventListener('click', this._onAddClick.bind(this));
|
||||
this._resetButton.addEventListener('click', this._onResetClick.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
|
||||
await this._updateOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').SentenceParsingTerminationCharacterOption} terminationCharacterEntry
|
||||
*/
|
||||
async addEntry(terminationCharacterEntry) {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {sentenceParsing: {terminationCharacters}} = options;
|
||||
|
||||
await this._settingsController.modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'sentenceParsing.terminationCharacters',
|
||||
start: terminationCharacters.length,
|
||||
deleteCount: 0,
|
||||
items: [terminationCharacterEntry],
|
||||
}]);
|
||||
|
||||
await this._updateOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async deleteEntry(index) {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {sentenceParsing: {terminationCharacters}} = options;
|
||||
|
||||
if (index < 0 || index >= terminationCharacters.length) { return false; }
|
||||
|
||||
await this._settingsController.modifyProfileSettings([{
|
||||
action: 'splice',
|
||||
path: 'sentenceParsing.terminationCharacters',
|
||||
start: index,
|
||||
deleteCount: 1,
|
||||
items: [],
|
||||
}]);
|
||||
|
||||
await this._updateOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async modifyProfileSettings(targets) {
|
||||
return await this._settingsController.modifyProfileSettings(targets);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
for (const entry of this._entries) {
|
||||
entry.cleanup();
|
||||
}
|
||||
|
||||
this._entries = [];
|
||||
const {sentenceParsing: {terminationCharacters}} = options;
|
||||
|
||||
const listContainer = /** @type {HTMLElement} */ (this._listContainer);
|
||||
for (let i = 0, ii = terminationCharacters.length; i < ii; ++i) {
|
||||
const terminationCharacterEntry = terminationCharacters[i];
|
||||
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('sentence-termination-character-entry'));
|
||||
listContainer.appendChild(node);
|
||||
const entry = new SentenceTerminationCharacterEntry(this, terminationCharacterEntry, i, node);
|
||||
this._entries.push(entry);
|
||||
entry.prepare();
|
||||
}
|
||||
|
||||
const empty = terminationCharacters.length === 0;
|
||||
/** @type {HTMLElement} */ (this._listTable).hidden = empty;
|
||||
/** @type {HTMLElement} */ (this._emptyIndicator).hidden = !empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onAddClick(e) {
|
||||
e.preventDefault();
|
||||
void this._addNewEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onResetClick(e) {
|
||||
e.preventDefault();
|
||||
void this._reset();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _addNewEntry() {
|
||||
const newEntry = {
|
||||
enabled: true,
|
||||
character1: '"',
|
||||
character2: '"',
|
||||
includeCharacterAtStart: false,
|
||||
includeCharacterAtEnd: false,
|
||||
};
|
||||
await this.addEntry(newEntry);
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateOptions() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/** */
|
||||
async _reset() {
|
||||
const defaultOptions = await this._settingsController.getDefaultOptions();
|
||||
const value = defaultOptions.profiles[0].options.sentenceParsing.terminationCharacters;
|
||||
await this._settingsController.setProfileSetting('sentenceParsing.terminationCharacters', value);
|
||||
await this._updateOptions();
|
||||
}
|
||||
}
|
||||
|
||||
class SentenceTerminationCharacterEntry {
|
||||
/**
|
||||
* @param {SentenceTerminationCharactersController} parent
|
||||
* @param {import('settings').SentenceParsingTerminationCharacterOption} data
|
||||
* @param {number} index
|
||||
* @param {HTMLElement} node
|
||||
*/
|
||||
constructor(parent, data, index, node) {
|
||||
/** @type {SentenceTerminationCharactersController} */
|
||||
this._parent = parent;
|
||||
/** @type {import('settings').SentenceParsingTerminationCharacterOption} */
|
||||
this._data = data;
|
||||
/** @type {number} */
|
||||
this._index = index;
|
||||
/** @type {HTMLElement} */
|
||||
this._node = node;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._character1Input = null;
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._character2Input = null;
|
||||
/** @type {string} */
|
||||
this._basePath = `sentenceParsing.terminationCharacters[${this._index}]`;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
const {enabled, character1, character2, includeCharacterAtStart, includeCharacterAtEnd} = this._data;
|
||||
const node = this._node;
|
||||
|
||||
/** @type {HTMLInputElement} */
|
||||
const enabledToggle = querySelectorNotNull(node, '.sentence-termination-character-enabled');
|
||||
/** @type {HTMLSelectElement} */
|
||||
const typeSelect = querySelectorNotNull(node, '.sentence-termination-character-type');
|
||||
/** @type {HTMLInputElement} */
|
||||
const character1Input = querySelectorNotNull(node, '.sentence-termination-character-input1');
|
||||
/** @type {HTMLInputElement} */
|
||||
const character2Input = querySelectorNotNull(node, '.sentence-termination-character-input2');
|
||||
/** @type {HTMLInputElement} */
|
||||
const includeAtStartCheckbox = querySelectorNotNull(node, '.sentence-termination-character-include-at-start');
|
||||
/** @type {HTMLInputElement} */
|
||||
const includeAtEndheckbox = querySelectorNotNull(node, '.sentence-termination-character-include-at-end');
|
||||
/** @type {HTMLButtonElement} */
|
||||
const menuButton = querySelectorNotNull(node, '.sentence-termination-character-entry-button');
|
||||
|
||||
this._character1Input = character1Input;
|
||||
this._character2Input = character2Input;
|
||||
|
||||
const type = (character2 === null ? 'terminator' : 'quote');
|
||||
node.dataset.type = type;
|
||||
|
||||
enabledToggle.checked = enabled;
|
||||
typeSelect.value = type;
|
||||
character1Input.value = character1;
|
||||
character2Input.value = (character2 !== null ? character2 : '');
|
||||
includeAtStartCheckbox.checked = includeCharacterAtStart;
|
||||
includeAtEndheckbox.checked = includeCharacterAtEnd;
|
||||
|
||||
enabledToggle.dataset.setting = `${this._basePath}.enabled`;
|
||||
includeAtStartCheckbox.dataset.setting = `${this._basePath}.includeCharacterAtStart`;
|
||||
includeAtEndheckbox.dataset.setting = `${this._basePath}.includeCharacterAtEnd`;
|
||||
|
||||
this._eventListeners.addEventListener(typeSelect, 'change', this._onTypeSelectChange.bind(this), false);
|
||||
this._eventListeners.addEventListener(character1Input, 'change', this._onCharacterChange.bind(this, 1), false);
|
||||
this._eventListeners.addEventListener(character2Input, 'change', this._onCharacterChange.bind(this, 2), false);
|
||||
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
if (this._node.parentNode !== null) {
|
||||
this._node.parentNode.removeChild(this._node);
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onTypeSelectChange(e) {
|
||||
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
|
||||
void this._setHasCharacter2(element.value === 'quote');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {1|2} characterNumber
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onCharacterChange(characterNumber, e) {
|
||||
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
if (characterNumber === 2 && this._data.character2 === null) {
|
||||
node.value = '';
|
||||
}
|
||||
|
||||
const value = node.value.substring(0, 1);
|
||||
void this._setCharacterValue(node, characterNumber, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onMenuClose(e) {
|
||||
switch (e.detail.action) {
|
||||
case 'delete':
|
||||
void this._delete();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _delete() {
|
||||
void this._parent.deleteEntry(this._index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} has
|
||||
*/
|
||||
async _setHasCharacter2(has) {
|
||||
if (this._character2Input === null) { return; }
|
||||
const okay = await this._setCharacterValue(this._character2Input, 2, has ? this._data.character1 : null);
|
||||
if (okay) {
|
||||
const type = (!has ? 'terminator' : 'quote');
|
||||
this._node.dataset.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLInputElement} inputNode
|
||||
* @param {1|2} characterNumber
|
||||
* @param {?string} value
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _setCharacterValue(inputNode, characterNumber, value) {
|
||||
if (characterNumber === 1 && typeof value !== 'string') { value = ''; }
|
||||
const r = await this._parent.settingsController.setProfileSetting(`${this._basePath}.character${characterNumber}`, value);
|
||||
const okay = !r[0].error;
|
||||
if (okay) {
|
||||
if (characterNumber === 1) {
|
||||
this._data.character1 = /** @type {string} */ (value);
|
||||
} else {
|
||||
this._data.character2 = value;
|
||||
}
|
||||
} else {
|
||||
value = characterNumber === 1 ? this._data.character1 : this._data.character2;
|
||||
}
|
||||
inputNode.value = (value !== null ? value : '');
|
||||
return okay;
|
||||
}
|
||||
}
|
||||
364
vendor/yomitan/js/pages/settings/settings-controller.js
vendored
Normal file
364
vendor/yomitan/js/pages/settings/settings-controller.js
vendored
Normal file
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventDispatcher} from '../../core/event-dispatcher.js';
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {isObjectNotArray} from '../../core/object-utilities.js';
|
||||
import {generateId} from '../../core/utilities.js';
|
||||
import {OptionsUtil} from '../../data/options-util.js';
|
||||
import {getAllPermissions} from '../../data/permissions-util.js';
|
||||
import {HtmlTemplateCollection} from '../../dom/html-template-collection.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('settings-controller').Events>
|
||||
*/
|
||||
export class SettingsController extends EventDispatcher {
|
||||
/**
|
||||
* @param {import('../../application.js').Application} application
|
||||
*/
|
||||
constructor(application) {
|
||||
super();
|
||||
/** @type {import('../../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {number} */
|
||||
this._profileIndex = 0;
|
||||
/** @type {string} */
|
||||
this._source = generateId(16);
|
||||
/** @type {Set<import('settings-controller').PageExitPrevention>} */
|
||||
this._pageExitPreventions = new Set();
|
||||
/** @type {EventListenerCollection} */
|
||||
this._pageExitPreventionEventListeners = new EventListenerCollection();
|
||||
/** @type {HtmlTemplateCollection} */
|
||||
this._templates = new HtmlTemplateCollection();
|
||||
}
|
||||
|
||||
/** @type {import('../../application.js').Application} */
|
||||
get application() {
|
||||
return this._application;
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
get source() {
|
||||
return this._source;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get profileIndex() {
|
||||
return this._profileIndex;
|
||||
}
|
||||
|
||||
set profileIndex(value) {
|
||||
if (this._profileIndex === value) { return; }
|
||||
this._setProfileIndex(value, true);
|
||||
}
|
||||
|
||||
/** */
|
||||
refreshProfileIndex() {
|
||||
this._setProfileIndex(this._profileIndex, true);
|
||||
}
|
||||
|
||||
/** @type {HtmlTemplateCollection} */
|
||||
get templates() {
|
||||
return this._templates;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
await this._templates.loadFromFiles(['/templates-settings.html']);
|
||||
this._application.on('optionsUpdated', this._onOptionsUpdated.bind(this));
|
||||
if (this._canObservePermissionsChanges()) {
|
||||
chrome.permissions.onAdded.addListener(this._onPermissionsChanged.bind(this));
|
||||
chrome.permissions.onRemoved.addListener(this._onPermissionsChanged.bind(this));
|
||||
}
|
||||
const optionsFull = await this.getOptionsFull();
|
||||
const {profiles, profileCurrent} = optionsFull;
|
||||
if (profileCurrent >= 0 && profileCurrent < profiles.length) {
|
||||
this._profileIndex = profileCurrent;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async refresh() {
|
||||
await this._onOptionsUpdatedInternal(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('settings').ProfileOptions>}
|
||||
*/
|
||||
async getOptions() {
|
||||
const optionsContext = this.getOptionsContext();
|
||||
return await this._application.api.optionsGet(optionsContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('settings').Options>}
|
||||
*/
|
||||
async getOptionsFull() {
|
||||
return await this._application.api.optionsGetFull();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').Options} value
|
||||
*/
|
||||
async setAllSettings(value) {
|
||||
const profileIndex = value.profileCurrent;
|
||||
await this._application.api.setAllSettings(value, this._source);
|
||||
this._setProfileIndex(profileIndex, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').ScopedRead[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async getSettings(targets) {
|
||||
return await this._getSettings(targets, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Read[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async getGlobalSettings(targets) {
|
||||
return await this._getSettings(targets, {scope: 'global', optionsContext: null});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Read[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async getProfileSettings(targets) {
|
||||
return await this._getSettings(targets, {scope: 'profile', optionsContext: null});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').ScopedModification[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async modifySettings(targets) {
|
||||
return await this._modifySettings(targets, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async modifyGlobalSettings(targets) {
|
||||
return await this._modifySettings(targets, {scope: 'global', optionsContext: null});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').Modification[]} targets
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async modifyProfileSettings(targets) {
|
||||
return await this._modifySettings(targets, {scope: 'profile', optionsContext: null});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async setGlobalSetting(path, value) {
|
||||
return await this.modifyGlobalSettings([{action: 'set', path, value}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async setProfileSetting(path, value) {
|
||||
return await this.modifyProfileSettings([{action: 'set', path, value}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('dictionary-importer').Summary[]>}
|
||||
*/
|
||||
async getDictionaryInfo() {
|
||||
return await this._application.api.getDictionaryInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('settings').OptionsContext}
|
||||
*/
|
||||
getOptionsContext() {
|
||||
return {index: this._profileIndex};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('settings-controller').PageExitPrevention}
|
||||
*/
|
||||
preventPageExit() {
|
||||
/** @type {import('settings-controller').PageExitPrevention} */
|
||||
// eslint-disable-next-line sonarjs/prefer-object-literal
|
||||
const obj = {};
|
||||
obj.end = this._endPreventPageExit.bind(this, obj);
|
||||
if (this._pageExitPreventionEventListeners.size === 0) {
|
||||
this._pageExitPreventionEventListeners.addEventListener(window, 'beforeunload', this._onBeforeUnload.bind(this), false);
|
||||
}
|
||||
this._pageExitPreventions.add(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Element}
|
||||
*/
|
||||
instantiateTemplate(name) {
|
||||
return this._templates.instantiate(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
instantiateTemplateFragment(name) {
|
||||
return this._templates.instantiateFragment(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('settings').Options>}
|
||||
*/
|
||||
async getDefaultOptions() {
|
||||
const optionsUtil = new OptionsUtil();
|
||||
await optionsUtil.prepare();
|
||||
return optionsUtil.getDefault();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {boolean} canUpdateProfileIndex
|
||||
*/
|
||||
_setProfileIndex(value, canUpdateProfileIndex) {
|
||||
this._profileIndex = value;
|
||||
this.trigger('optionsContextChanged', {});
|
||||
void this._onOptionsUpdatedInternal(canUpdateProfileIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{source: string}} details
|
||||
*/
|
||||
_onOptionsUpdated({source}) {
|
||||
if (source === this._source) { return; }
|
||||
void this._onOptionsUpdatedInternal(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} canUpdateProfileIndex
|
||||
*/
|
||||
async _onOptionsUpdatedInternal(canUpdateProfileIndex) {
|
||||
const optionsContext = this.getOptionsContext();
|
||||
try {
|
||||
const options = await this.getOptions();
|
||||
this.trigger('optionsChanged', {options, optionsContext});
|
||||
} catch (e) {
|
||||
if (canUpdateProfileIndex) {
|
||||
this._setProfileIndex(0, false);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-modifications').OptionsScope} target
|
||||
*/
|
||||
_modifyOptionsScope(target) {
|
||||
if (target.scope === 'profile') {
|
||||
target.optionsContext = this.getOptionsContext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {boolean} THasScope
|
||||
* @param {import('settings-controller').SettingsRead<THasScope>[]} targets
|
||||
* @param {import('settings-controller').SettingsExtraFields<THasScope>} extraFields
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async _getSettings(targets, extraFields) {
|
||||
const targets2 = targets.map((target) => {
|
||||
const target2 = /** @type {import('settings-controller').SettingsRead<true>} */ (Object.assign({}, extraFields, target));
|
||||
this._modifyOptionsScope(target2);
|
||||
return target2;
|
||||
});
|
||||
return await this._application.api.getSettings(targets2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {boolean} THasScope
|
||||
* @param {import('settings-controller').SettingsModification<THasScope>[]} targets
|
||||
* @param {import('settings-controller').SettingsExtraFields<THasScope>} extraFields
|
||||
* @returns {Promise<import('settings-controller').ModifyResult[]>}
|
||||
*/
|
||||
async _modifySettings(targets, extraFields) {
|
||||
const targets2 = targets.map((target) => {
|
||||
const target2 = /** @type {import('settings-controller').SettingsModification<true>} */ (Object.assign({}, extraFields, target));
|
||||
this._modifyOptionsScope(target2);
|
||||
return target2;
|
||||
});
|
||||
return await this._application.api.modifySettings(targets2, this._source);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BeforeUnloadEvent} e
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
_onBeforeUnload(e) {
|
||||
if (this._pageExitPreventions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').PageExitPrevention} obj
|
||||
*/
|
||||
_endPreventPageExit(obj) {
|
||||
this._pageExitPreventions.delete(obj);
|
||||
if (this._pageExitPreventions.size === 0) {
|
||||
this._pageExitPreventionEventListeners.removeAllEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_onPermissionsChanged() {
|
||||
void this._triggerPermissionsChanged();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _triggerPermissionsChanged() {
|
||||
const eventName = 'permissionsChanged';
|
||||
if (!this.hasListeners(eventName)) { return; }
|
||||
|
||||
const permissions = await getAllPermissions();
|
||||
this.trigger(eventName, {permissions});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_canObservePermissionsChanges() {
|
||||
return isObjectNotArray(chrome.permissions) && isObjectNotArray(chrome.permissions.onAdded) && isObjectNotArray(chrome.permissions.onRemoved);
|
||||
}
|
||||
}
|
||||
416
vendor/yomitan/js/pages/settings/settings-display-controller.js
vendored
Normal file
416
vendor/yomitan/js/pages/settings/settings-display-controller.js
vendored
Normal file
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ThemeController} from '../../app/theme-controller.js';
|
||||
import {isInputElementFocused} from '../../dom/document-util.js';
|
||||
import {PopupMenu} from '../../dom/popup-menu.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {SelectorObserver} from '../../dom/selector-observer.js';
|
||||
|
||||
export class SettingsDisplayController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
* @param {import('./modal-controller.js').ModalController} modalController
|
||||
*/
|
||||
constructor(settingsController, modalController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {import('./modal-controller.js').ModalController} */
|
||||
this._modalController = modalController;
|
||||
/** @type {HTMLElement} */
|
||||
this._contentNode = querySelectorNotNull(document, '.content');
|
||||
/** @type {HTMLElement} */
|
||||
this._menuContainer = querySelectorNotNull(document, '#popup-menus');
|
||||
/** @type {(event: MouseEvent) => void} */
|
||||
this._onMoreToggleClickBind = this._onMoreToggleClick.bind(this);
|
||||
/** @type {(event: MouseEvent) => void} */
|
||||
this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this);
|
||||
/** @type {ThemeController} */
|
||||
this._themeController = new ThemeController(document.documentElement);
|
||||
/** @type {HTMLSelectElement | null}*/
|
||||
this._themeDropdown = document.querySelector('[data-setting="general.popupTheme"]');
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._themeController.prepare();
|
||||
await this._setTheme();
|
||||
|
||||
const onFabButtonClick = this._onFabButtonClick.bind(this);
|
||||
for (const fabButton of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.fab-button'))) {
|
||||
fabButton.addEventListener('click', onFabButtonClick, false);
|
||||
}
|
||||
|
||||
const onModalAction = this._onModalAction.bind(this);
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-modal-action]'))) {
|
||||
node.addEventListener('click', onModalAction, false);
|
||||
}
|
||||
|
||||
const onSelectOnClickElementClick = this._onSelectOnClickElementClick.bind(this);
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-select-on-click]'))) {
|
||||
node.addEventListener('click', onSelectOnClickElementClick, false);
|
||||
}
|
||||
|
||||
const onInputTabActionKeyDown = this._onInputTabActionKeyDown.bind(this);
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-tab-action]'))) {
|
||||
node.addEventListener('keydown', onInputTabActionKeyDown, false);
|
||||
}
|
||||
|
||||
for (const node of /** @type {NodeListOf<HTMLIFrameElement>} */ (document.querySelectorAll('.defer-load-iframe'))) {
|
||||
this._setupDeferLoadIframe(node);
|
||||
}
|
||||
|
||||
const moreSelectorObserver = new SelectorObserver({
|
||||
selector: '.more-toggle',
|
||||
onAdded: this._onMoreSetup.bind(this),
|
||||
onRemoved: this._onMoreCleanup.bind(this),
|
||||
});
|
||||
moreSelectorObserver.observe(document.documentElement, false);
|
||||
|
||||
const menuSelectorObserver = new SelectorObserver({
|
||||
selector: '[data-menu]',
|
||||
onAdded: this._onMenuSetup.bind(this),
|
||||
onRemoved: this._onMenuCleanup.bind(this),
|
||||
});
|
||||
menuSelectorObserver.observe(document.documentElement, false);
|
||||
|
||||
window.addEventListener('keydown', this._onKeyDown.bind(this), false);
|
||||
|
||||
if (this._themeDropdown) {
|
||||
this._themeDropdown.addEventListener('change', this._updateTheme.bind(this), false);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _setTheme() {
|
||||
this._themeController.theme = (await this._settingsController.getOptions()).general.popupTheme;
|
||||
this._themeController.siteOverride = true;
|
||||
this._themeController.updateTheme();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateTheme() {
|
||||
const theme = this._themeDropdown?.value;
|
||||
if (theme === 'site' || theme === 'light' || theme === 'dark' || theme === 'browser') {
|
||||
this._themeController.theme = theme;
|
||||
}
|
||||
this._themeController.siteOverride = true;
|
||||
this._themeController.updateTheme();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {null}
|
||||
*/
|
||||
_onMoreSetup(element) {
|
||||
/** @type {HTMLElement} */ (element).addEventListener('click', this._onMoreToggleClickBind, false);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
*/
|
||||
_onMoreCleanup(element) {
|
||||
/** @type {HTMLElement} */ (element).removeEventListener('click', this._onMoreToggleClickBind, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {null}
|
||||
*/
|
||||
_onMenuSetup(element) {
|
||||
/** @type {HTMLElement} */ (element).addEventListener('click', this._onMenuButtonClickBind, false);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
*/
|
||||
_onMenuCleanup(element) {
|
||||
/** @type {HTMLElement} */ (element).removeEventListener('click', this._onMenuButtonClickBind, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onMenuButtonClick(e) {
|
||||
const element = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const {menu} = element.dataset;
|
||||
if (typeof menu === 'undefined') { return; }
|
||||
this._showMenu(element, menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onFabButtonClick(e) {
|
||||
const element = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const action = element.dataset.action;
|
||||
switch (action) {
|
||||
case 'toggle-sidebar':
|
||||
document.body.classList.toggle('sidebar-visible');
|
||||
break;
|
||||
case 'toggle-preview-sidebar':
|
||||
document.body.classList.toggle('preview-sidebar-visible');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onMoreToggleClick(e) {
|
||||
const node = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const container = this._getMoreContainer(node);
|
||||
if (container === null) { return; }
|
||||
|
||||
/** @type {?HTMLElement} */
|
||||
const more = container.querySelector('.more');
|
||||
if (more === null) { return; }
|
||||
|
||||
const moreVisible = more.hidden;
|
||||
more.hidden = !moreVisible;
|
||||
for (const moreToggle of /** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll('.more-toggle'))) {
|
||||
const container2 = this._getMoreContainer(moreToggle);
|
||||
if (container2 === null) { continue; }
|
||||
|
||||
const more2 = container2.querySelector('.more');
|
||||
if (more2 === null || more2 !== more) { continue; }
|
||||
|
||||
moreToggle.dataset.expanded = `${moreVisible}`;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_onKeyDown(e) {
|
||||
switch (e.code) {
|
||||
case 'Escape':
|
||||
if (!isInputElementFocused()) {
|
||||
this._closeTopMenuOrModal();
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onModalAction(e) {
|
||||
const node = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const {modalAction} = node.dataset;
|
||||
if (typeof modalAction !== 'string') { return; }
|
||||
|
||||
const modalActionArray = modalAction.split(',');
|
||||
const action = modalActionArray[0];
|
||||
/** @type {string|Element|undefined} */
|
||||
let target = modalActionArray[1];
|
||||
if (typeof target === 'undefined') {
|
||||
const currentModal = node.closest('.modal');
|
||||
if (currentModal === null) { return; }
|
||||
target = currentModal;
|
||||
}
|
||||
|
||||
const modal = this._modalController.getModal(target);
|
||||
if (modal === null) { return; }
|
||||
|
||||
switch (action) {
|
||||
case 'show':
|
||||
modal.setVisible(true);
|
||||
break;
|
||||
case 'hide':
|
||||
modal.setVisible(false);
|
||||
break;
|
||||
case 'toggle':
|
||||
modal.setVisible(!modal.isVisible());
|
||||
break;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onSelectOnClickElementClick(e) {
|
||||
if (e.button !== 0) { return; }
|
||||
|
||||
const node = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const range = document.createRange();
|
||||
range.selectNode(node);
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection !== null) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_onInputTabActionKeyDown(e) {
|
||||
if (e.key !== 'Tab' || e.ctrlKey) { return; }
|
||||
|
||||
const node = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const {tabAction} = node.dataset;
|
||||
if (typeof tabAction !== 'string') { return; }
|
||||
|
||||
const args = tabAction.split(',');
|
||||
switch (args[0]) {
|
||||
case 'ignore':
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'indent':
|
||||
e.preventDefault();
|
||||
this._indentInput(e, node, args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} link
|
||||
* @returns {?Element}
|
||||
*/
|
||||
_getMoreContainer(link) {
|
||||
const v = link.dataset.parentDistance;
|
||||
const distance = v ? Number.parseInt(v, 10) : 1;
|
||||
if (Number.isNaN(distance)) { return null; }
|
||||
|
||||
/** @type {?Element} */
|
||||
let result = link;
|
||||
for (let i = 0; i < distance; ++i) {
|
||||
if (result === null) { break; }
|
||||
result = /** @type {?Element} */ (result.parentNode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** */
|
||||
_closeTopMenuOrModal() {
|
||||
for (const popupMenu of PopupMenu.openMenus) {
|
||||
popupMenu.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = this._modalController.getTopVisibleModal();
|
||||
if (modal !== null && !modal.forceInteract) {
|
||||
modal.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} menuName
|
||||
*/
|
||||
_showMenu(element, menuName) {
|
||||
const menu = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate(menuName));
|
||||
|
||||
/** @type {HTMLElement} */ (this._menuContainer).appendChild(menu);
|
||||
|
||||
const popupMenu = new PopupMenu(element, menu);
|
||||
popupMenu.prepare();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
* @param {HTMLElement} node
|
||||
* @param {string[]} args
|
||||
*/
|
||||
_indentInput(e, node, args) {
|
||||
if (!(node instanceof HTMLTextAreaElement)) { return; }
|
||||
|
||||
let indent = '\t';
|
||||
if (args.length > 1) {
|
||||
const count = Number.parseInt(args[1], 10);
|
||||
indent = (Number.isFinite(count) && count >= 0 ? ' '.repeat(count) : args[1]);
|
||||
}
|
||||
|
||||
const {selectionStart: start, selectionEnd: end, value} = node;
|
||||
const lineStart = value.substring(0, start).lastIndexOf('\n') + 1;
|
||||
const lineWhitespaceMatch = /^[ \t]*/.exec(value.substring(lineStart));
|
||||
const lineWhitespace = lineWhitespaceMatch !== null ? lineWhitespaceMatch[0] : '';
|
||||
|
||||
if (e.shiftKey) {
|
||||
const whitespaceLength = Math.max(0, Math.floor((lineWhitespace.length - 1) / 4) * 4);
|
||||
const selectionStartNew = lineStart + whitespaceLength;
|
||||
const selectionEndNew = lineStart + lineWhitespace.length;
|
||||
const removeCount = selectionEndNew - selectionStartNew;
|
||||
if (removeCount > 0) {
|
||||
node.selectionStart = selectionStartNew;
|
||||
node.selectionEnd = selectionEndNew;
|
||||
document.execCommand('delete', false);
|
||||
node.selectionStart = Math.max(lineStart, start - removeCount);
|
||||
node.selectionEnd = Math.max(lineStart, end - removeCount);
|
||||
}
|
||||
} else {
|
||||
if (indent.length > 0) {
|
||||
const indentLength = (Math.ceil((start - lineStart + 1) / indent.length) * indent.length - (start - lineStart));
|
||||
document.execCommand('insertText', false, indent.substring(0, indentLength));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLIFrameElement} element
|
||||
*/
|
||||
_setupDeferLoadIframe(element) {
|
||||
const parent = this._getMoreContainer(element);
|
||||
if (parent === null) { return; }
|
||||
|
||||
/** @type {?MutationObserver} */
|
||||
let mutationObserver = null;
|
||||
const callback = () => {
|
||||
if (!this._isElementVisible(element)) { return false; }
|
||||
|
||||
const src = element.dataset.src;
|
||||
delete element.dataset.src;
|
||||
if (typeof src === 'string') {
|
||||
element.src = src;
|
||||
}
|
||||
|
||||
if (mutationObserver === null) { return true; }
|
||||
|
||||
mutationObserver.disconnect();
|
||||
mutationObserver = null;
|
||||
return true;
|
||||
};
|
||||
|
||||
if (callback()) { return; }
|
||||
|
||||
mutationObserver = new MutationObserver(callback);
|
||||
mutationObserver.observe(parent, {attributes: true});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isElementVisible(element) {
|
||||
return (element.offsetParent !== null);
|
||||
}
|
||||
}
|
||||
188
vendor/yomitan/js/pages/settings/settings-main.js
vendored
Normal file
188
vendor/yomitan/js/pages/settings/settings-main.js
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Application} from '../../application.js';
|
||||
import {DocumentFocusController} from '../../dom/document-focus-controller.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
import {ExtensionContentController} from '../common/extension-content-controller.js';
|
||||
import {AnkiController} from './anki-controller.js';
|
||||
import {AnkiDeckGeneratorController} from './anki-deck-generator-controller.js';
|
||||
import {AnkiTemplatesController} from './anki-templates-controller.js';
|
||||
import {AudioController} from './audio-controller.js';
|
||||
import {BackupController} from './backup-controller.js';
|
||||
import {CollapsibleDictionaryController} from './collapsible-dictionary-controller.js';
|
||||
import {DictionaryController} from './dictionary-controller.js';
|
||||
import {DictionaryImportController} from './dictionary-import-controller.js';
|
||||
import {ExtensionKeyboardShortcutController} from './extension-keyboard-shortcuts-controller.js';
|
||||
import {GenericSettingController} from './generic-setting-controller.js';
|
||||
import {KeyboardShortcutController} from './keyboard-shortcuts-controller.js';
|
||||
import {LanguagesController} from './languages-controller.js';
|
||||
import {MecabController} from './mecab-controller.js';
|
||||
import {ModalController} from './modal-controller.js';
|
||||
import {NestedPopupsController} from './nested-popups-controller.js';
|
||||
import {PermissionsToggleController} from './permissions-toggle-controller.js';
|
||||
import {PersistentStorageController} from './persistent-storage-controller.js';
|
||||
import {PopupPreviewController} from './popup-preview-controller.js';
|
||||
import {PopupWindowController} from './popup-window-controller.js';
|
||||
import {ProfileController} from './profile-controller.js';
|
||||
import {RecommendedSettingsController} from './recommended-settings-controller.js';
|
||||
import {ScanInputsController} from './scan-inputs-controller.js';
|
||||
import {ScanInputsSimpleController} from './scan-inputs-simple-controller.js';
|
||||
import {SecondarySearchDictionaryController} from './secondary-search-dictionary-controller.js';
|
||||
import {SentenceTerminationCharactersController} from './sentence-termination-characters-controller.js';
|
||||
import {SettingsController} from './settings-controller.js';
|
||||
import {SettingsDisplayController} from './settings-display-controller.js';
|
||||
import {SortFrequencyDictionaryController} from './sort-frequency-dictionary-controller.js';
|
||||
import {StatusFooter} from './status-footer.js';
|
||||
import {StorageController} from './storage-controller.js';
|
||||
import {TranslationTextReplacementsController} from './translation-text-replacements-controller.js';
|
||||
import {YomitanApiController} from './yomitan-api-controller.js';
|
||||
|
||||
/**
|
||||
* @param {GenericSettingController} genericSettingController
|
||||
*/
|
||||
async function setupGenericSettingController(genericSettingController) {
|
||||
await genericSettingController.prepare();
|
||||
await genericSettingController.refresh();
|
||||
}
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const documentFocusController = new DocumentFocusController();
|
||||
documentFocusController.prepare();
|
||||
|
||||
const extensionContentController = new ExtensionContentController();
|
||||
extensionContentController.prepare();
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const statusFooterElement = querySelectorNotNull(document, '.status-footer-container');
|
||||
const statusFooter = new StatusFooter(statusFooterElement);
|
||||
statusFooter.prepare();
|
||||
|
||||
/** @type {?number} */
|
||||
let prepareTimer = window.setTimeout(() => {
|
||||
prepareTimer = null;
|
||||
document.documentElement.dataset.loadingStalled = 'true';
|
||||
}, 1000);
|
||||
|
||||
if (prepareTimer !== null) {
|
||||
clearTimeout(prepareTimer);
|
||||
prepareTimer = null;
|
||||
}
|
||||
delete document.documentElement.dataset.loadingStalled;
|
||||
|
||||
const preparePromises = [];
|
||||
|
||||
const modalController = new ModalController(['shared-modals', 'settings-modals']);
|
||||
await modalController.prepare();
|
||||
|
||||
const settingsController = new SettingsController(application);
|
||||
await settingsController.prepare();
|
||||
|
||||
const settingsDisplayController = new SettingsDisplayController(settingsController, modalController);
|
||||
await settingsDisplayController.prepare();
|
||||
|
||||
document.body.hidden = false;
|
||||
|
||||
const popupPreviewController = new PopupPreviewController(settingsController);
|
||||
popupPreviewController.prepare();
|
||||
|
||||
const persistentStorageController = new PersistentStorageController(application);
|
||||
preparePromises.push(persistentStorageController.prepare());
|
||||
|
||||
const storageController = new StorageController(persistentStorageController);
|
||||
storageController.prepare();
|
||||
|
||||
const dictionaryController = new DictionaryController(settingsController, modalController, statusFooter);
|
||||
preparePromises.push(dictionaryController.prepare());
|
||||
|
||||
const dictionaryImportController = new DictionaryImportController(settingsController, modalController, statusFooter);
|
||||
dictionaryImportController.prepare();
|
||||
|
||||
const genericSettingController = new GenericSettingController(settingsController);
|
||||
preparePromises.push(setupGenericSettingController(genericSettingController));
|
||||
|
||||
const audioController = new AudioController(settingsController, modalController);
|
||||
preparePromises.push(audioController.prepare());
|
||||
|
||||
const profileController = new ProfileController(settingsController, modalController);
|
||||
preparePromises.push(profileController.prepare());
|
||||
|
||||
const settingsBackup = new BackupController(settingsController, modalController);
|
||||
preparePromises.push(settingsBackup.prepare());
|
||||
|
||||
const ankiController = new AnkiController(settingsController, application, modalController);
|
||||
preparePromises.push(ankiController.prepare());
|
||||
|
||||
const ankiDeckGeneratorController = new AnkiDeckGeneratorController(application, settingsController, modalController, ankiController);
|
||||
preparePromises.push(ankiDeckGeneratorController.prepare());
|
||||
|
||||
const ankiTemplatesController = new AnkiTemplatesController(application, settingsController, modalController, ankiController);
|
||||
preparePromises.push(ankiTemplatesController.prepare());
|
||||
|
||||
const scanInputsController = new ScanInputsController(settingsController);
|
||||
preparePromises.push(scanInputsController.prepare());
|
||||
|
||||
const simpleScanningInputController = new ScanInputsSimpleController(settingsController);
|
||||
preparePromises.push(simpleScanningInputController.prepare());
|
||||
|
||||
const nestedPopupsController = new NestedPopupsController(settingsController);
|
||||
preparePromises.push(nestedPopupsController.prepare());
|
||||
|
||||
const permissionsToggleController = new PermissionsToggleController(settingsController);
|
||||
preparePromises.push(permissionsToggleController.prepare());
|
||||
|
||||
const secondarySearchDictionaryController = new SecondarySearchDictionaryController(settingsController);
|
||||
preparePromises.push(secondarySearchDictionaryController.prepare());
|
||||
|
||||
const languagesController = new LanguagesController(settingsController);
|
||||
preparePromises.push(languagesController.prepare());
|
||||
|
||||
const translationTextReplacementsController = new TranslationTextReplacementsController(settingsController);
|
||||
preparePromises.push(translationTextReplacementsController.prepare());
|
||||
|
||||
const sentenceTerminationCharactersController = new SentenceTerminationCharactersController(settingsController);
|
||||
preparePromises.push(sentenceTerminationCharactersController.prepare());
|
||||
|
||||
const keyboardShortcutController = new KeyboardShortcutController(settingsController);
|
||||
preparePromises.push(keyboardShortcutController.prepare());
|
||||
|
||||
const extensionKeyboardShortcutController = new ExtensionKeyboardShortcutController(settingsController);
|
||||
preparePromises.push(extensionKeyboardShortcutController.prepare());
|
||||
|
||||
const popupWindowController = new PopupWindowController(application.api);
|
||||
popupWindowController.prepare();
|
||||
|
||||
const mecabController = new MecabController(application.api);
|
||||
mecabController.prepare();
|
||||
|
||||
const yomitanApiController = new YomitanApiController(application.api);
|
||||
yomitanApiController.prepare();
|
||||
|
||||
const collapsibleDictionaryController = new CollapsibleDictionaryController(settingsController);
|
||||
preparePromises.push(collapsibleDictionaryController.prepare());
|
||||
|
||||
const sortFrequencyDictionaryController = new SortFrequencyDictionaryController(settingsController);
|
||||
preparePromises.push(sortFrequencyDictionaryController.prepare());
|
||||
|
||||
const recommendedSettingsController = new RecommendedSettingsController(settingsController);
|
||||
preparePromises.push(recommendedSettingsController.prepare());
|
||||
|
||||
await Promise.all(preparePromises);
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
238
vendor/yomitan/js/pages/settings/sort-frequency-dictionary-controller.js
vendored
Normal file
238
vendor/yomitan/js/pages/settings/sort-frequency-dictionary-controller.js
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class SortFrequencyDictionaryController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._sortFrequencyDictionarySelect = querySelectorNotNull(document, '#sort-frequency-dictionary');
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._sortFrequencyDictionaryOrderSelect = querySelectorNotNull(document, '#sort-frequency-dictionary-order');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._sortFrequencyDictionaryOrderAutoButton = querySelectorNotNull(document, '#sort-frequency-dictionary-order-auto');
|
||||
/** @type {HTMLElement} */
|
||||
this._sortFrequencyDictionaryOrderContainerNode = querySelectorNotNull(document, '#sort-frequency-dictionary-order-container');
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._getDictionaryInfoToken = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
await this._onDatabaseUpdated();
|
||||
|
||||
this._settingsController.application.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
this._sortFrequencyDictionarySelect.addEventListener('change', this._onSortFrequencyDictionarySelectChange.bind(this));
|
||||
this._sortFrequencyDictionaryOrderSelect.addEventListener('change', this._onSortFrequencyDictionaryOrderSelectChange.bind(this));
|
||||
this._sortFrequencyDictionaryOrderAutoButton.addEventListener('click', this._onSortFrequencyDictionaryOrderAutoButtonClick.bind(this));
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _onDatabaseUpdated() {
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._getDictionaryInfoToken = token;
|
||||
const dictionaries = await this._settingsController.getDictionaryInfo();
|
||||
if (this._getDictionaryInfoToken !== token) { return; }
|
||||
this._getDictionaryInfoToken = null;
|
||||
|
||||
this._updateDictionaryOptions(dictionaries);
|
||||
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
const {sortFrequencyDictionary, sortFrequencyDictionaryOrder} = options.general;
|
||||
/** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect).value = (sortFrequencyDictionary !== null ? sortFrequencyDictionary : '');
|
||||
/** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect).value = sortFrequencyDictionaryOrder;
|
||||
/** @type {HTMLElement} */ (this._sortFrequencyDictionaryOrderContainerNode).hidden = (sortFrequencyDictionary === null);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onSortFrequencyDictionarySelectChange() {
|
||||
const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
|
||||
void this._setSortFrequencyDictionaryValue(value !== '' ? value : null);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onSortFrequencyDictionaryOrderSelectChange() {
|
||||
const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect);
|
||||
const value2 = this._normalizeSortFrequencyDictionaryOrder(value);
|
||||
if (value2 === null) { return; }
|
||||
void this._setSortFrequencyDictionaryOrderValue(value2);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onSortFrequencyDictionaryOrderAutoButtonClick() {
|
||||
const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
|
||||
if (value === '') { return; }
|
||||
void this._autoUpdateOrder(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-importer').Summary[]} dictionaries
|
||||
*/
|
||||
_updateDictionaryOptions(dictionaries) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'None';
|
||||
fragment.appendChild(option);
|
||||
for (const {title, counts} of dictionaries) {
|
||||
if (counts && counts.termMeta && counts.termMeta.freq > 0) {
|
||||
option = document.createElement('option');
|
||||
option.value = title;
|
||||
option.textContent = title;
|
||||
fragment.appendChild(option);
|
||||
}
|
||||
}
|
||||
const select = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
|
||||
select.textContent = '';
|
||||
select.appendChild(fragment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?string} value
|
||||
*/
|
||||
async _setSortFrequencyDictionaryValue(value) {
|
||||
/** @type {HTMLElement} */ (this._sortFrequencyDictionaryOrderContainerNode).hidden = (value === null);
|
||||
await this._settingsController.setProfileSetting('general.sortFrequencyDictionary', value);
|
||||
if (value !== null) {
|
||||
await this._autoUpdateOrder(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').SortFrequencyDictionaryOrder} value
|
||||
*/
|
||||
async _setSortFrequencyDictionaryOrderValue(value) {
|
||||
await this._settingsController.setProfileSetting('general.sortFrequencyDictionaryOrder', value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dictionary
|
||||
*/
|
||||
async _autoUpdateOrder(dictionary) {
|
||||
const order = await this._getFrequencyOrder(dictionary);
|
||||
if (order === 0) { return; }
|
||||
const value = (order > 0 ? 'descending' : 'ascending');
|
||||
/** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect).value = value;
|
||||
await this._setSortFrequencyDictionaryOrderValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dictionary
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async _getFrequencyOrder(dictionary) {
|
||||
const dictionaryInfo = await this._settingsController.application.api.getDictionaryInfo();
|
||||
const dictionaryLang = dictionaryInfo.find(({title}) => title === dictionary)?.sourceLanguage ?? '';
|
||||
|
||||
/** @type {Record<string, string[]>} */
|
||||
const moreCommonTerms = {
|
||||
ja: ['来る', '言う', '出る', '入る', '方', '男', '女', '今', '何', '時'],
|
||||
};
|
||||
/** @type {Record<string, string[]>} */
|
||||
const lessCommonTerms = {
|
||||
ja: ['行なう', '論じる', '過す', '行方', '人口', '猫', '犬', '滝', '理', '暁'],
|
||||
};
|
||||
let langMoreCommonTerms = moreCommonTerms[dictionaryLang];
|
||||
let langLessCommonTerms = lessCommonTerms[dictionaryLang];
|
||||
if (dictionaryLang === '') {
|
||||
langMoreCommonTerms = [];
|
||||
for (const key in moreCommonTerms) {
|
||||
if (Object.hasOwn(moreCommonTerms, key)) {
|
||||
langMoreCommonTerms.push(...moreCommonTerms[key]);
|
||||
}
|
||||
}
|
||||
langLessCommonTerms = [];
|
||||
for (const key in lessCommonTerms) {
|
||||
if (Object.hasOwn(lessCommonTerms, key)) {
|
||||
langLessCommonTerms.push(...lessCommonTerms[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const terms = [...langMoreCommonTerms, ...langLessCommonTerms];
|
||||
|
||||
const frequencies = await this._settingsController.application.api.getTermFrequencies(
|
||||
terms.map((term) => ({term, reading: null})),
|
||||
[dictionary],
|
||||
);
|
||||
|
||||
/** @type {Map<string, {hasValue: boolean, minValue: number, maxValue: number}>} */
|
||||
const termDetails = new Map();
|
||||
const moreCommonTermDetails = [];
|
||||
const lessCommonTermDetails = [];
|
||||
for (const term of langMoreCommonTerms) {
|
||||
const details = {hasValue: false, minValue: Number.MAX_SAFE_INTEGER, maxValue: Number.MIN_SAFE_INTEGER};
|
||||
termDetails.set(term, details);
|
||||
moreCommonTermDetails.push(details);
|
||||
}
|
||||
for (const term of langLessCommonTerms) {
|
||||
const details = {hasValue: false, minValue: Number.MAX_SAFE_INTEGER, maxValue: Number.MIN_SAFE_INTEGER};
|
||||
termDetails.set(term, details);
|
||||
lessCommonTermDetails.push(details);
|
||||
}
|
||||
|
||||
for (const {term, frequency} of frequencies) {
|
||||
const details = termDetails.get(term);
|
||||
if (typeof details === 'undefined') { continue; }
|
||||
details.minValue = Math.min(details.minValue, frequency);
|
||||
details.maxValue = Math.max(details.maxValue, frequency);
|
||||
details.hasValue = true;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (const details1 of moreCommonTermDetails) {
|
||||
if (!details1.hasValue) { continue; }
|
||||
for (const details2 of lessCommonTermDetails) {
|
||||
if (!details2.hasValue) { continue; }
|
||||
result += Math.sign(details1.maxValue - details2.minValue) + Math.sign(details1.minValue - details2.maxValue);
|
||||
}
|
||||
}
|
||||
return Math.sign(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {?import('settings').SortFrequencyDictionaryOrder}
|
||||
*/
|
||||
_normalizeSortFrequencyDictionaryOrder(value) {
|
||||
switch (value) {
|
||||
case 'ascending':
|
||||
case 'descending':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
vendor/yomitan/js/pages/settings/status-footer.js
vendored
Normal file
104
vendor/yomitan/js/pages/settings/status-footer.js
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {PanelElement} from '../../dom/panel-element.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class StatusFooter extends PanelElement {
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
*/
|
||||
constructor(node) {
|
||||
super(node, 375); // Milliseconds; includes buffer
|
||||
/** @type {HTMLElement} */
|
||||
this._body = querySelectorNotNull(node, '.status-footer');
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
/** @type {HTMLElement} */
|
||||
const closeButton = querySelectorNotNull(this._body, '.status-footer-header-close');
|
||||
this.on('closeCompleted', this._onCloseCompleted.bind(this));
|
||||
closeButton.addEventListener('click', this._onCloseClick.bind(this), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @returns {?HTMLElement}
|
||||
*/
|
||||
getTaskContainer(selector) {
|
||||
return this._body.querySelector(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTaskActive(selector) {
|
||||
const target = this.getTaskContainer(selector);
|
||||
return (target !== null && !!target.dataset.active);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @param {boolean} active
|
||||
*/
|
||||
setTaskActive(selector, active) {
|
||||
const target = this.getTaskContainer(selector);
|
||||
if (target === null) { return; }
|
||||
|
||||
const activeElements = new Set();
|
||||
for (const element of /** @type {NodeListOf<HTMLElement>} */ (this._body.querySelectorAll('.status-footer-item'))) {
|
||||
if (element.dataset.active) {
|
||||
activeElements.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
target.dataset.active = 'true';
|
||||
if (!this.isVisible()) {
|
||||
this.setVisible(true);
|
||||
}
|
||||
target.hidden = false;
|
||||
} else {
|
||||
delete target.dataset.active;
|
||||
if (activeElements.size <= 1) {
|
||||
this.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onCloseClick(e) {
|
||||
e.preventDefault();
|
||||
this.setVisible(false);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onCloseCompleted() {
|
||||
for (const element of /** @type {NodeListOf<HTMLElement>} */ (this._body.querySelectorAll('.status-footer-item'))) {
|
||||
if (!element.dataset.active) {
|
||||
element.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
169
vendor/yomitan/js/pages/settings/storage-controller.js
vendored
Normal file
169
vendor/yomitan/js/pages/settings/storage-controller.js
vendored
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class StorageController {
|
||||
/**
|
||||
* @param {import('./persistent-storage-controller.js').PersistentStorageController} persistentStorageController
|
||||
*/
|
||||
constructor(persistentStorageController) {
|
||||
/** @type {import('./persistent-storage-controller.js').PersistentStorageController} */
|
||||
this._persistentStorageController = persistentStorageController;
|
||||
/** @type {?StorageEstimate} */
|
||||
this._mostRecentStorageEstimate = null;
|
||||
/** @type {boolean} */
|
||||
this._storageEstimateFailed = false;
|
||||
/** @type {boolean} */
|
||||
this._isUpdating = false;
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._storageUsageNodes = null;
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._storageQuotaNodes = null;
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._storageUseFiniteNodes = null;
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._storageUseExhaustWarnNodes = null;
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._storageUseInfiniteNodes = null;
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._storageUseValidNodes = null;
|
||||
/** @type {?NodeListOf<HTMLElement>} */
|
||||
this._storageUseInvalidNodes = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._storageUsageNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-usage'));
|
||||
this._storageQuotaNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-quota'));
|
||||
this._storageUseFiniteNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-finite'));
|
||||
this._storageUseExhaustWarnNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-exhaustion-alert'));
|
||||
this._storageUseInfiniteNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-infinite'));
|
||||
this._storageUseValidNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-valid'));
|
||||
this._storageUseInvalidNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-invalid'));
|
||||
/** @type {HTMLButtonElement} */
|
||||
const storageRefreshButton = querySelectorNotNull(document, '#storage-refresh');
|
||||
|
||||
storageRefreshButton.addEventListener('click', this._onStorageRefreshButtonClick.bind(this), false);
|
||||
this._persistentStorageController.application.on('storageChanged', this._onStorageChanged.bind(this));
|
||||
|
||||
void this._updateStats();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onStorageRefreshButtonClick() {
|
||||
void this._updateStats();
|
||||
}
|
||||
|
||||
/** */
|
||||
_onStorageChanged() {
|
||||
void this._updateStats();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateStats() {
|
||||
if (this._isUpdating) { return; }
|
||||
|
||||
try {
|
||||
this._isUpdating = true;
|
||||
|
||||
const estimate = await this._storageEstimate();
|
||||
const valid = (estimate !== null);
|
||||
let storageIsLow = false;
|
||||
|
||||
// Firefox reports usage as 0 when persistent storage is enabled.
|
||||
const finite = valid && ((typeof estimate.usage === 'number' && estimate.usage > 0) || !(await this._persistentStorageController.isStoragePeristent()));
|
||||
if (finite) {
|
||||
let {usage, quota} = estimate;
|
||||
|
||||
if (typeof usage !== 'number') { usage = 0; }
|
||||
if (typeof quota !== 'number') {
|
||||
quota = 0;
|
||||
} else {
|
||||
storageIsLow = quota <= (3 * 1000000000);
|
||||
}
|
||||
const usageString = this._bytesToLabeledString(usage);
|
||||
const quotaString = this._bytesToLabeledString(quota);
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._storageUsageNodes)) {
|
||||
node.textContent = usageString;
|
||||
}
|
||||
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._storageQuotaNodes)) {
|
||||
node.textContent = quotaString;
|
||||
}
|
||||
}
|
||||
|
||||
this._setElementsVisible(this._storageUseFiniteNodes, valid && finite);
|
||||
this._setElementsVisible(this._storageUseInfiniteNodes, valid && !finite);
|
||||
this._setElementsVisible(this._storageUseValidNodes, valid);
|
||||
this._setElementsVisible(this._storageUseInvalidNodes, !valid);
|
||||
this._setElementsVisible(this._storageUseExhaustWarnNodes, storageIsLow);
|
||||
} finally {
|
||||
this._isUpdating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @returns {Promise<?StorageEstimate>}
|
||||
*/
|
||||
async _storageEstimate() {
|
||||
if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = await navigator.storage.estimate();
|
||||
this._mostRecentStorageEstimate = value;
|
||||
return value;
|
||||
} catch (e) {
|
||||
this._storageEstimateFailed = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} size
|
||||
* @returns {string}
|
||||
*/
|
||||
_bytesToLabeledString(size) {
|
||||
const base = 1000;
|
||||
const labels = [' bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const maxLabelIndex = labels.length - 1;
|
||||
let labelIndex = 0;
|
||||
while (size >= base && labelIndex < maxLabelIndex) {
|
||||
size /= base;
|
||||
++labelIndex;
|
||||
}
|
||||
const label = labelIndex === 0 ? `${size}` : size.toFixed(1);
|
||||
return `${label}${labels[labelIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?NodeListOf<HTMLElement>} elements
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
_setElementsVisible(elements, visible) {
|
||||
if (elements === null) { return; }
|
||||
visible = !visible;
|
||||
for (const element of elements) {
|
||||
element.hidden = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
324
vendor/yomitan/js/pages/settings/translation-text-replacements-controller.js
vendored
Normal file
324
vendor/yomitan/js/pages/settings/translation-text-replacements-controller.js
vendored
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2021-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventListenerCollection} from '../../core/event-listener-collection.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class TranslationTextReplacementsController {
|
||||
/**
|
||||
* @param {import('./settings-controller.js').SettingsController} settingsController
|
||||
*/
|
||||
constructor(settingsController) {
|
||||
/** @type {import('./settings-controller.js').SettingsController} */
|
||||
this._settingsController = settingsController;
|
||||
/** @type {HTMLElement} */
|
||||
this._entryContainer = querySelectorNotNull(document, '#translation-text-replacement-list');
|
||||
/** @type {TranslationTextReplacementsEntry[]} */
|
||||
this._entries = [];
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
/** @type {HTMLButtonElement} */
|
||||
const addButton = querySelectorNotNull(document, '#translation-text-replacement-add');
|
||||
|
||||
addButton.addEventListener('click', this._onAdd.bind(this), false);
|
||||
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
|
||||
|
||||
await this._updateOptions();
|
||||
}
|
||||
|
||||
/** */
|
||||
async addGroup() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {groups} = options.translation.textReplacements;
|
||||
const newEntry = this._createNewEntry();
|
||||
/** @type {import('settings-modifications').Modification} */
|
||||
const target = (
|
||||
(groups.length === 0) ?
|
||||
{
|
||||
action: 'splice',
|
||||
path: 'translation.textReplacements.groups',
|
||||
start: 0,
|
||||
deleteCount: 0,
|
||||
items: [[newEntry]],
|
||||
} :
|
||||
{
|
||||
action: 'splice',
|
||||
path: 'translation.textReplacements.groups[0]',
|
||||
start: groups[0].length,
|
||||
deleteCount: 0,
|
||||
items: [newEntry],
|
||||
}
|
||||
);
|
||||
|
||||
await this._settingsController.modifyProfileSettings([target]);
|
||||
await this._updateOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async deleteGroup(index) {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const {groups} = options.translation.textReplacements;
|
||||
if (groups.length === 0) { return false; }
|
||||
|
||||
const group0 = groups[0];
|
||||
if (index < 0 || index >= group0.length) { return false; }
|
||||
|
||||
/** @type {import('settings-modifications').Modification} */
|
||||
const target = (
|
||||
(group0.length > 1) ?
|
||||
{
|
||||
action: 'splice',
|
||||
path: 'translation.textReplacements.groups[0]',
|
||||
start: index,
|
||||
deleteCount: 1,
|
||||
items: [],
|
||||
} :
|
||||
{
|
||||
action: 'splice',
|
||||
path: 'translation.textReplacements.groups',
|
||||
start: 0,
|
||||
deleteCount: group0.length,
|
||||
items: [],
|
||||
}
|
||||
);
|
||||
|
||||
await this._settingsController.modifyProfileSettings([target]);
|
||||
await this._updateOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
|
||||
*/
|
||||
_onOptionsChanged({options}) {
|
||||
for (const entry of this._entries) {
|
||||
entry.cleanup();
|
||||
}
|
||||
this._entries = [];
|
||||
|
||||
const {groups} = options.translation.textReplacements;
|
||||
if (groups.length > 0) {
|
||||
const group0 = groups[0];
|
||||
for (let i = 0, ii = group0.length; i < ii; ++i) {
|
||||
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('translation-text-replacement-entry'));
|
||||
/** @type {HTMLElement} */ (this._entryContainer).appendChild(node);
|
||||
const entry = new TranslationTextReplacementsEntry(this, node, i);
|
||||
this._entries.push(entry);
|
||||
entry.prepare();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_onAdd() {
|
||||
void this.addGroup();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateOptions() {
|
||||
const options = await this._settingsController.getOptions();
|
||||
const optionsContext = this._settingsController.getOptionsContext();
|
||||
this._onOptionsChanged({options, optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('settings').TranslationTextReplacementGroup}
|
||||
*/
|
||||
_createNewEntry() {
|
||||
return {pattern: '', ignoreCase: false, replacement: ''};
|
||||
}
|
||||
}
|
||||
|
||||
class TranslationTextReplacementsEntry {
|
||||
/**
|
||||
* @param {TranslationTextReplacementsController} parent
|
||||
* @param {HTMLElement} node
|
||||
* @param {number} index
|
||||
*/
|
||||
constructor(parent, node, index) {
|
||||
/** @type {TranslationTextReplacementsController} */
|
||||
this._parent = parent;
|
||||
/** @type {HTMLElement} */
|
||||
this._node = node;
|
||||
/** @type {number} */
|
||||
this._index = index;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._patternInput = null;
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._replacementInput = null;
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._ignoreCaseToggle = null;
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._testInput = null;
|
||||
/** @type {?HTMLInputElement} */
|
||||
this._testOutput = null;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
/** @type {HTMLInputElement} */
|
||||
const patternInput = querySelectorNotNull(this._node, '.translation-text-replacement-pattern');
|
||||
/** @type {HTMLInputElement} */
|
||||
const replacementInput = querySelectorNotNull(this._node, '.translation-text-replacement-replacement');
|
||||
/** @type {HTMLInputElement} */
|
||||
const ignoreCaseToggle = querySelectorNotNull(this._node, '.translation-text-replacement-pattern-ignore-case');
|
||||
/** @type {HTMLInputElement} */
|
||||
const menuButton = querySelectorNotNull(this._node, '.translation-text-replacement-button');
|
||||
/** @type {HTMLInputElement} */
|
||||
const testInput = querySelectorNotNull(this._node, '.translation-text-replacement-test-input');
|
||||
/** @type {HTMLInputElement} */
|
||||
const testOutput = querySelectorNotNull(this._node, '.translation-text-replacement-test-output');
|
||||
|
||||
this._patternInput = patternInput;
|
||||
this._replacementInput = replacementInput;
|
||||
this._ignoreCaseToggle = ignoreCaseToggle;
|
||||
this._testInput = testInput;
|
||||
this._testOutput = testOutput;
|
||||
|
||||
const pathBase = `translation.textReplacements.groups[0][${this._index}]`;
|
||||
patternInput.dataset.setting = `${pathBase}.pattern`;
|
||||
replacementInput.dataset.setting = `${pathBase}.replacement`;
|
||||
ignoreCaseToggle.dataset.setting = `${pathBase}.ignoreCase`;
|
||||
|
||||
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
|
||||
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
|
||||
this._eventListeners.addEventListener(patternInput, 'settingChanged', this._onPatternChanged.bind(this), false);
|
||||
this._eventListeners.addEventListener(ignoreCaseToggle, 'settingChanged', this._updateTestInput.bind(this), false);
|
||||
this._eventListeners.addEventListener(replacementInput, 'settingChanged', this._updateTestInput.bind(this), false);
|
||||
this._eventListeners.addEventListener(testInput, 'input', this._updateTestInput.bind(this), false);
|
||||
}
|
||||
|
||||
/** */
|
||||
cleanup() {
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
if (this._node.parentNode !== null) {
|
||||
this._node.parentNode.removeChild(this._node);
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuOpenEvent} e
|
||||
*/
|
||||
_onMenuOpen(e) {
|
||||
const bodyNode = e.detail.menu.bodyNode;
|
||||
const testVisible = this._isTestVisible();
|
||||
/** @type {HTMLElement} */
|
||||
const element1 = querySelectorNotNull(bodyNode, '[data-menu-action=showTest]');
|
||||
/** @type {HTMLElement} */
|
||||
const element2 = querySelectorNotNull(bodyNode, '[data-menu-action=hideTest]');
|
||||
element1.hidden = testVisible;
|
||||
element2.hidden = !testVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup-menu').MenuCloseEvent} e
|
||||
*/
|
||||
_onMenuClose(e) {
|
||||
switch (e.detail.action) {
|
||||
case 'remove':
|
||||
void this._parent.deleteGroup(this._index);
|
||||
break;
|
||||
case 'showTest':
|
||||
this._setTestVisible(true);
|
||||
break;
|
||||
case 'hideTest':
|
||||
this._setTestVisible(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').SettingChangedEvent} deatils
|
||||
*/
|
||||
_onPatternChanged({detail: {value}}) {
|
||||
this._validatePattern(value);
|
||||
this._updateTestInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
_validatePattern(value) {
|
||||
let okay = false;
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
// eslint-disable-next-line no-new
|
||||
new RegExp(value, 'g');
|
||||
okay = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
if (this._patternInput !== null) {
|
||||
this._patternInput.dataset.invalid = `${!okay}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isTestVisible() {
|
||||
return this._node.dataset.testVisible === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
_setTestVisible(visible) {
|
||||
this._node.dataset.testVisible = `${visible}`;
|
||||
this._updateTestInput();
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateTestInput() {
|
||||
if (
|
||||
!this._isTestVisible() ||
|
||||
this._ignoreCaseToggle === null ||
|
||||
this._patternInput === null ||
|
||||
this._replacementInput === null ||
|
||||
this._testInput === null ||
|
||||
this._testOutput === null
|
||||
) { return; }
|
||||
|
||||
const ignoreCase = this._ignoreCaseToggle.checked;
|
||||
const pattern = this._patternInput.value;
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(pattern, ignoreCase ? 'gi' : 'g');
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replacement = this._replacementInput.value;
|
||||
const input = this._testInput.value;
|
||||
const output = input.replace(regex, replacement);
|
||||
this._testOutput.value = output;
|
||||
}
|
||||
}
|
||||
83
vendor/yomitan/js/pages/settings/yomitan-api-controller.js
vendored
Normal file
83
vendor/yomitan/js/pages/settings/yomitan-api-controller.js
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Yomitan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {toError} from '../../core/to-error.js';
|
||||
import {querySelectorNotNull} from '../../dom/query-selector.js';
|
||||
|
||||
export class YomitanApiController {
|
||||
/**
|
||||
* @param {import('../../comm/api.js').API} api
|
||||
*/
|
||||
constructor(api) {
|
||||
/** @type {import('../../comm/api.js').API} */
|
||||
this._api = api;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._testButton = querySelectorNotNull(document, '#test-yomitan-api-button');
|
||||
/** @type {HTMLElement} */
|
||||
this._resultsContainer = querySelectorNotNull(document, '#test-yomitan-api-results');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._urlInput = querySelectorNotNull(document, '#test-yomitan-url-input');
|
||||
/** @type {boolean} */
|
||||
this._testActive = false;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._testButton.addEventListener('click', this._onTestButtonClick.bind(this), false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onTestButtonClick(e) {
|
||||
e.preventDefault();
|
||||
void this._testYomitanApi();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _testYomitanApi() {
|
||||
if (this._testActive) { return; }
|
||||
|
||||
try {
|
||||
this._testActive = true;
|
||||
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
|
||||
/** @type {HTMLButtonElement} */ (this._testButton).disabled = true;
|
||||
resultsContainer.textContent = '';
|
||||
resultsContainer.hidden = true;
|
||||
await this._api.testYomitanApi(this._urlInput.value);
|
||||
this._setStatus('Connection was successful', false);
|
||||
} catch (e) {
|
||||
this._setStatus(toError(e).message, true);
|
||||
} finally {
|
||||
this._testActive = false;
|
||||
/** @type {HTMLButtonElement} */ (this._testButton).disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {boolean} isError
|
||||
*/
|
||||
_setStatus(message, isError) {
|
||||
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
|
||||
resultsContainer.textContent = message;
|
||||
resultsContainer.hidden = false;
|
||||
resultsContainer.classList.toggle('danger-text', isError);
|
||||
}
|
||||
}
|
||||
41
vendor/yomitan/js/pages/support-main.js
vendored
Normal file
41
vendor/yomitan/js/pages/support-main.js
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Yomitan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ThemeController} from '../app/theme-controller.js';
|
||||
import {Application} from '../application.js';
|
||||
import {SettingsController} from './settings/settings-controller.js';
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const settingsController = new SettingsController(application);
|
||||
await settingsController.prepare();
|
||||
|
||||
/** @type {ThemeController} */
|
||||
const themeController = new ThemeController(document.documentElement);
|
||||
themeController.prepare();
|
||||
const optionsFull = await application.api.optionsGetFull();
|
||||
const {profiles, profileCurrent} = optionsFull;
|
||||
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
|
||||
if (defaultProfile !== null) {
|
||||
themeController.theme = defaultProfile.options.general.popupTheme;
|
||||
themeController.siteOverride = true;
|
||||
themeController.updateTheme();
|
||||
}
|
||||
|
||||
document.body.hidden = false;
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
119
vendor/yomitan/js/pages/welcome-main.js
vendored
Normal file
119
vendor/yomitan/js/pages/welcome-main.js
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Application} from '../application.js';
|
||||
import {DocumentFocusController} from '../dom/document-focus-controller.js';
|
||||
import {querySelectorNotNull} from '../dom/query-selector.js';
|
||||
import {ExtensionContentController} from './common/extension-content-controller.js';
|
||||
import {DataTransmissionConsentController} from './settings/data-transmission-consent-controller.js';
|
||||
import {DictionaryController} from './settings/dictionary-controller.js';
|
||||
import {DictionaryImportController} from './settings/dictionary-import-controller.js';
|
||||
import {GenericSettingController} from './settings/generic-setting-controller.js';
|
||||
import {LanguagesController} from './settings/languages-controller.js';
|
||||
import {ModalController} from './settings/modal-controller.js';
|
||||
import {RecommendedPermissionsController} from './settings/recommended-permissions-controller.js';
|
||||
import {RecommendedSettingsController} from './settings/recommended-settings-controller.js';
|
||||
import {ScanInputsSimpleController} from './settings/scan-inputs-simple-controller.js';
|
||||
import {SettingsController} from './settings/settings-controller.js';
|
||||
import {SettingsDisplayController} from './settings/settings-display-controller.js';
|
||||
import {StatusFooter} from './settings/status-footer.js';
|
||||
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
*/
|
||||
async function setupEnvironmentInfo(api) {
|
||||
const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
|
||||
const {browser, platform} = await api.getEnvironmentInfo();
|
||||
document.documentElement.dataset.browser = browser;
|
||||
document.documentElement.dataset.os = platform.os;
|
||||
document.documentElement.dataset.manifestVersion = `${manifestVersion}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GenericSettingController} genericSettingController
|
||||
*/
|
||||
async function setupGenericSettingsController(genericSettingController) {
|
||||
await genericSettingController.prepare();
|
||||
await genericSettingController.refresh();
|
||||
}
|
||||
|
||||
/** */
|
||||
async function checkNeedsCustomTemplatesWarning() {
|
||||
const key = 'needsCustomTemplatesWarning';
|
||||
const result = await chrome.storage.session.get({[key]: false});
|
||||
if (!result[key]) { return; }
|
||||
document.documentElement.dataset.warnCustomTemplates = 'true';
|
||||
await chrome.storage.session.remove([key]);
|
||||
}
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const modalController = new ModalController(['shared-modals', 'settings-modals']);
|
||||
await modalController.prepare();
|
||||
|
||||
const settingsController = new SettingsController(application);
|
||||
await settingsController.prepare();
|
||||
|
||||
const settingsDisplayController = new SettingsDisplayController(settingsController, modalController);
|
||||
await settingsDisplayController.prepare();
|
||||
|
||||
document.body.hidden = false;
|
||||
|
||||
const documentFocusController = new DocumentFocusController();
|
||||
documentFocusController.prepare();
|
||||
|
||||
const extensionContentController = new ExtensionContentController();
|
||||
extensionContentController.prepare();
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const statusFooterElement = querySelectorNotNull(document, '.status-footer-container');
|
||||
const statusFooter = new StatusFooter(statusFooterElement);
|
||||
statusFooter.prepare();
|
||||
|
||||
void setupEnvironmentInfo(application.api);
|
||||
void checkNeedsCustomTemplatesWarning();
|
||||
|
||||
const preparePromises = [];
|
||||
|
||||
const genericSettingController = new GenericSettingController(settingsController);
|
||||
preparePromises.push(setupGenericSettingsController(genericSettingController));
|
||||
|
||||
const dictionaryController = new DictionaryController(settingsController, modalController, statusFooter);
|
||||
preparePromises.push(dictionaryController.prepare());
|
||||
|
||||
const dictionaryImportController = new DictionaryImportController(settingsController, modalController, statusFooter);
|
||||
preparePromises.push(dictionaryImportController.prepare());
|
||||
|
||||
const simpleScanningInputController = new ScanInputsSimpleController(settingsController);
|
||||
preparePromises.push(simpleScanningInputController.prepare());
|
||||
|
||||
const recommendedPermissionsController = new RecommendedPermissionsController(settingsController);
|
||||
preparePromises.push(recommendedPermissionsController.prepare());
|
||||
|
||||
const languagesController = new LanguagesController(settingsController);
|
||||
preparePromises.push(languagesController.prepare());
|
||||
|
||||
const recommendedSettingsController = new RecommendedSettingsController(settingsController);
|
||||
preparePromises.push(recommendedSettingsController.prepare());
|
||||
|
||||
const dataTransmissionConsentController = new DataTransmissionConsentController(settingsController, modalController);
|
||||
preparePromises.push(dataTransmissionConsentController.prepare());
|
||||
|
||||
await Promise.all(preparePromises);
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
Reference in New Issue
Block a user