mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 18:22:42 -08:00
initial commit
This commit is contained in:
124
vendor/yomitan/js/accessibility/accessibility-controller.js
vendored
Normal file
124
vendor/yomitan/js/accessibility/accessibility-controller.js
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 {isContentScriptRegistered, registerContentScript, unregisterContentScript} from '../background/script-manager.js';
|
||||
import {log} from '../core/log.js';
|
||||
|
||||
/**
|
||||
* This class controls the registration of accessibility handlers.
|
||||
*/
|
||||
export class AccessibilityController {
|
||||
constructor() {
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._updateGoogleDocsAccessibilityToken = null;
|
||||
/** @type {?Promise<void>} */
|
||||
this._updateGoogleDocsAccessibilityPromise = null;
|
||||
/** @type {boolean} */
|
||||
this._forceGoogleDocsHtmlRenderingAny = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the accessibility handlers.
|
||||
* @param {import('settings').Options} fullOptions The full options object from the `Backend` instance.
|
||||
* The value is treated as read-only and is not modified.
|
||||
*/
|
||||
async update(fullOptions) {
|
||||
let forceGoogleDocsHtmlRenderingAny = false;
|
||||
for (const {options} of fullOptions.profiles) {
|
||||
if (options.accessibility.forceGoogleDocsHtmlRendering) {
|
||||
forceGoogleDocsHtmlRenderingAny = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this._updateGoogleDocsAccessibility(forceGoogleDocsHtmlRenderingAny);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {boolean} forceGoogleDocsHtmlRenderingAny
|
||||
*/
|
||||
async _updateGoogleDocsAccessibility(forceGoogleDocsHtmlRenderingAny) {
|
||||
// Reentrant token
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._updateGoogleDocsAccessibilityToken = token;
|
||||
|
||||
// Wait for previous
|
||||
let promise = this._updateGoogleDocsAccessibilityPromise;
|
||||
if (promise !== null) { await promise; }
|
||||
|
||||
// Reentrant check
|
||||
if (this._updateGoogleDocsAccessibilityToken !== token) { return; }
|
||||
|
||||
// Update
|
||||
promise = this._updateGoogleDocsAccessibilityInner(forceGoogleDocsHtmlRenderingAny);
|
||||
this._updateGoogleDocsAccessibilityPromise = promise;
|
||||
await promise;
|
||||
this._updateGoogleDocsAccessibilityPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} forceGoogleDocsHtmlRenderingAny
|
||||
*/
|
||||
async _updateGoogleDocsAccessibilityInner(forceGoogleDocsHtmlRenderingAny) {
|
||||
if (this._forceGoogleDocsHtmlRenderingAny === forceGoogleDocsHtmlRenderingAny) { return; }
|
||||
|
||||
this._forceGoogleDocsHtmlRenderingAny = forceGoogleDocsHtmlRenderingAny;
|
||||
|
||||
const id = 'googleDocsAccessibility';
|
||||
try {
|
||||
if (forceGoogleDocsHtmlRenderingAny) {
|
||||
if (await isContentScriptRegistered(id)) { return; }
|
||||
try {
|
||||
await this._registerGoogleDocsContentScript(id, false);
|
||||
} catch (e) {
|
||||
// Firefox doesn't support `world` field and will throw an error.
|
||||
// In this case, use the xray vision version.
|
||||
await this._registerGoogleDocsContentScript(id, true);
|
||||
}
|
||||
} else {
|
||||
await unregisterContentScript(id);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {boolean} xray
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_registerGoogleDocsContentScript(id, xray) {
|
||||
/** @type {import('script-manager').RegistrationDetails} */
|
||||
const details = {
|
||||
allFrames: true,
|
||||
matches: ['*://docs.google.com/*'],
|
||||
runAt: 'document_start',
|
||||
js: [
|
||||
xray ?
|
||||
'js/accessibility/google-docs-xray.js' :
|
||||
'js/accessibility/google-docs.js',
|
||||
],
|
||||
};
|
||||
if (!xray) { details.world = 'MAIN'; }
|
||||
return registerContentScript(id, details);
|
||||
}
|
||||
}
|
||||
159
vendor/yomitan/js/accessibility/google-docs-util.js
vendored
Normal file
159
vendor/yomitan/js/accessibility/google-docs-util.js
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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 {computeZoomScale, isPointInAnyRect} from '../dom/document-util.js';
|
||||
import {TextSourceRange} from '../dom/text-source-range.js';
|
||||
|
||||
/**
|
||||
* This class is a helper for handling Google Docs content in content scripts.
|
||||
*/
|
||||
export class GoogleDocsUtil {
|
||||
constructor() {
|
||||
/** @type {?HTMLStyleElement} */
|
||||
this._styleNode = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the document for text or elements with text information at the given coordinate.
|
||||
* Coordinates are provided in [client space](https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_View/Coordinate_systems).
|
||||
* @param {number} x The x coordinate to search at.
|
||||
* @param {number} y The y coordinate to search at.
|
||||
* @param {import('document-util').GetRangeFromPointOptions} options Options to configure how element detection is performed.
|
||||
* @returns {?TextSourceRange} A range for the hovered text or element, or `null` if no applicable content was found.
|
||||
*/
|
||||
getRangeFromPoint(x, y, {normalizeCssZoom}) {
|
||||
const styleNode = this._getStyleNode();
|
||||
styleNode.disabled = false;
|
||||
const element = document.elementFromPoint(x, y);
|
||||
styleNode.disabled = true;
|
||||
if (element !== null && element.matches('.kix-canvas-tile-content svg>g>rect')) {
|
||||
const ariaLabel = element.getAttribute('aria-label');
|
||||
if (typeof ariaLabel === 'string' && ariaLabel.length > 0) {
|
||||
return this._createRange(element, ariaLabel, x, y, normalizeCssZoom);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets this <style> node, or creates one if it doesn't exist.
|
||||
*
|
||||
* A <style> node is necessary to force the SVG <rect> elements to have a fill,
|
||||
* which allows them to be included in document.elementsFromPoint's return value.
|
||||
* @returns {HTMLStyleElement}
|
||||
*/
|
||||
_getStyleNode() {
|
||||
if (this._styleNode === null) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = [
|
||||
'.kix-canvas-tile-content{pointer-events:none!important;}',
|
||||
'.kix-canvas-tile-content svg>g>rect{pointer-events:all!important;}',
|
||||
].join('\n');
|
||||
const parent = document.head || document.documentElement;
|
||||
if (parent !== null) {
|
||||
parent.appendChild(style);
|
||||
}
|
||||
this._styleNode = style;
|
||||
}
|
||||
return this._styleNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {string} text
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {boolean} normalizeCssZoom
|
||||
* @returns {TextSourceRange}
|
||||
*/
|
||||
_createRange(element, text, x, y, normalizeCssZoom) {
|
||||
// Create imposter
|
||||
const content = document.createTextNode(text);
|
||||
const svgText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
const transform = element.getAttribute('transform') || '';
|
||||
// Using getAttribute instead of dataset because element is an SVG element
|
||||
// eslint-disable-next-line unicorn/prefer-dom-node-dataset
|
||||
const font = element.getAttribute('data-font-css') || '';
|
||||
const elementX = element.getAttribute('x');
|
||||
const elementY = element.getAttribute('y');
|
||||
if (typeof elementX === 'string') { svgText.setAttribute('x', elementX); }
|
||||
if (typeof elementY === 'string') { svgText.setAttribute('y', elementY); }
|
||||
svgText.appendChild(content);
|
||||
const textStyle = svgText.style;
|
||||
this._setImportantStyle(textStyle, 'all', 'initial');
|
||||
this._setImportantStyle(textStyle, 'transform', transform);
|
||||
this._setImportantStyle(textStyle, 'font', font);
|
||||
this._setImportantStyle(textStyle, 'text-anchor', 'start');
|
||||
const {parentNode} = element;
|
||||
if (parentNode !== null) {
|
||||
parentNode.appendChild(svgText);
|
||||
}
|
||||
|
||||
// Adjust offset
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const textRect = svgText.getBoundingClientRect();
|
||||
const yOffset = ((elementRect.top - textRect.top) + (elementRect.bottom - textRect.bottom)) * 0.5;
|
||||
this._setImportantStyle(textStyle, 'transform', `translate(0px,${yOffset}px) ${transform}`);
|
||||
|
||||
// Create range
|
||||
const range = this._getRangeWithPoint(content, x, y, normalizeCssZoom);
|
||||
this._setImportantStyle(textStyle, 'pointer-events', 'none');
|
||||
this._setImportantStyle(textStyle, 'opacity', '0');
|
||||
return TextSourceRange.createFromImposter(range, svgText, element);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Text} textNode
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {boolean} normalizeCssZoom
|
||||
* @returns {Range}
|
||||
*/
|
||||
_getRangeWithPoint(textNode, x, y, normalizeCssZoom) {
|
||||
if (normalizeCssZoom) {
|
||||
const scale = computeZoomScale(textNode);
|
||||
x /= scale;
|
||||
y /= scale;
|
||||
}
|
||||
const range = document.createRange();
|
||||
let start = 0;
|
||||
let end = /** @type {string} */ (textNode.nodeValue).length;
|
||||
while (end - start > 1) {
|
||||
const mid = Math.floor((start + end) / 2);
|
||||
range.setStart(textNode, mid);
|
||||
range.setEnd(textNode, end);
|
||||
if (isPointInAnyRect(x, y, range.getClientRects(), null)) {
|
||||
start = mid;
|
||||
} else {
|
||||
end = mid;
|
||||
}
|
||||
}
|
||||
range.setStart(textNode, start);
|
||||
range.setEnd(textNode, start);
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CSSStyleDeclaration} style
|
||||
* @param {string} propertyName
|
||||
* @param {string} value
|
||||
*/
|
||||
_setImportantStyle(style, propertyName, value) {
|
||||
style.setProperty(propertyName, value, 'important');
|
||||
}
|
||||
}
|
||||
30
vendor/yomitan/js/accessibility/google-docs-xray.js
vendored
Normal file
30
vendor/yomitan/js/accessibility/google-docs-xray.js
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Entry point. */
|
||||
function main() {
|
||||
/** @type {unknown} */
|
||||
// @ts-expect-error - Firefox Xray vision
|
||||
const window2 = window.wrappedJSObject;
|
||||
if (!(typeof window2 === 'object' && window2 !== null)) { return; }
|
||||
// The extension ID below is on an allow-list that is used on the Google Docs webpage.
|
||||
// @ts-expect-error - Adding a property to the global object
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
window2._docs_annotate_canvas_by_ext = 'ogmnaimimemjmbakcfefmnahgdfhfami';
|
||||
}
|
||||
|
||||
main();
|
||||
21
vendor/yomitan/js/accessibility/google-docs.js
vendored
Normal file
21
vendor/yomitan/js/accessibility/google-docs.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The extension ID below is on an allow-list that is used on the Google Docs webpage.
|
||||
// @ts-expect-error - Adding a property to the global object
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
window._docs_annotate_canvas_by_ext = 'ogmnaimimemjmbakcfefmnahgdfhfami';
|
||||
45
vendor/yomitan/js/app/content-script-main.js
vendored
Normal file
45
vendor/yomitan/js/app/content-script-main.js
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 {HotkeyHandler} from '../input/hotkey-handler.js';
|
||||
import {Frontend} from './frontend.js';
|
||||
import {PopupFactory} from './popup-factory.js';
|
||||
|
||||
await Application.main(false, async (application) => {
|
||||
const hotkeyHandler = new HotkeyHandler();
|
||||
hotkeyHandler.prepare(application.crossFrame);
|
||||
|
||||
const popupFactory = new PopupFactory(application);
|
||||
popupFactory.prepare();
|
||||
|
||||
const frontend = new Frontend({
|
||||
application,
|
||||
popupFactory,
|
||||
depth: 0,
|
||||
parentPopupId: null,
|
||||
parentFrameId: null,
|
||||
useProxyPopup: false,
|
||||
pageType: 'web',
|
||||
canUseWindowPopup: true,
|
||||
allowRootFramePopupProxy: true,
|
||||
childrenSupported: true,
|
||||
hotkeyHandler,
|
||||
});
|
||||
await frontend.prepare();
|
||||
});
|
||||
23
vendor/yomitan/js/app/content-script-wrapper.js
vendored
Normal file
23
vendor/yomitan/js/app/content-script-wrapper.js
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
(async () => {
|
||||
const src = chrome.runtime.getURL('js/app/content-script-main.js');
|
||||
// eslint-disable-next-line no-unsanitized/method
|
||||
await import(src);
|
||||
})();
|
||||
1054
vendor/yomitan/js/app/frontend.js
vendored
Normal file
1054
vendor/yomitan/js/app/frontend.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
418
vendor/yomitan/js/app/popup-factory.js
vendored
Normal file
418
vendor/yomitan/js/app/popup-factory.js
vendored
Normal file
@@ -0,0 +1,418 @@
|
||||
/*
|
||||
* 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 {FrameOffsetForwarder} from '../comm/frame-offset-forwarder.js';
|
||||
import {generateId} from '../core/utilities.js';
|
||||
import {PopupProxy} from './popup-proxy.js';
|
||||
import {PopupWindow} from './popup-window.js';
|
||||
import {Popup} from './popup.js';
|
||||
|
||||
/**
|
||||
* A class which is used to generate and manage popups.
|
||||
*/
|
||||
export class PopupFactory {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
* @param {import('../application.js').Application} application
|
||||
*/
|
||||
constructor(application) {
|
||||
/** @type {import('../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {FrameOffsetForwarder} */
|
||||
this._frameOffsetForwarder = new FrameOffsetForwarder(application.crossFrame);
|
||||
/** @type {Map<string, import('popup').PopupAny>} */
|
||||
this._popups = new Map();
|
||||
/** @type {Map<string, {popup: import('popup').PopupAny, token: string}[]>} */
|
||||
this._allPopupVisibilityTokenMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the instance for use.
|
||||
*/
|
||||
prepare() {
|
||||
this._frameOffsetForwarder.prepare();
|
||||
/* eslint-disable @stylistic/no-multi-spaces */
|
||||
this._application.crossFrame.registerHandlers([
|
||||
['popupFactoryGetOrCreatePopup', this._onApiGetOrCreatePopup.bind(this)],
|
||||
['popupFactorySetOptionsContext', this._onApiSetOptionsContext.bind(this)],
|
||||
['popupFactoryHide', this._onApiHide.bind(this)],
|
||||
['popupFactoryIsVisible', this._onApiIsVisibleAsync.bind(this)],
|
||||
['popupFactorySetVisibleOverride', this._onApiSetVisibleOverride.bind(this)],
|
||||
['popupFactoryClearVisibleOverride', this._onApiClearVisibleOverride.bind(this)],
|
||||
['popupFactoryContainsPoint', this._onApiContainsPoint.bind(this)],
|
||||
['popupFactoryShowContent', this._onApiShowContent.bind(this)],
|
||||
['popupFactorySetCustomCss', this._onApiSetCustomCss.bind(this)],
|
||||
['popupFactoryClearAutoPlayTimer', this._onApiClearAutoPlayTimer.bind(this)],
|
||||
['popupFactorySetContentScale', this._onApiSetContentScale.bind(this)],
|
||||
['popupFactoryUpdateTheme', this._onApiUpdateTheme.bind(this)],
|
||||
['popupFactorySetCustomOuterCss', this._onApiSetCustomOuterCss.bind(this)],
|
||||
['popupFactoryGetFrameSize', this._onApiGetFrameSize.bind(this)],
|
||||
['popupFactorySetFrameSize', this._onApiSetFrameSize.bind(this)],
|
||||
['popupFactoryIsPointerOver', this._onApiIsPointerOver.bind(this)],
|
||||
]);
|
||||
/* eslint-enable @stylistic/no-multi-spaces */
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a popup based on a set of parameters
|
||||
* @param {import('popup-factory').GetOrCreatePopupDetails} details Details about how to acquire the popup.
|
||||
* @returns {Promise<import('popup').PopupAny>}
|
||||
*/
|
||||
async getOrCreatePopup({
|
||||
frameId = null,
|
||||
id = null,
|
||||
parentPopupId = null,
|
||||
depth = null,
|
||||
popupWindow = false,
|
||||
childrenSupported = false,
|
||||
}) {
|
||||
// Find by existing id
|
||||
if (id !== null) {
|
||||
const popup = this._popups.get(id);
|
||||
if (typeof popup !== 'undefined') {
|
||||
return popup;
|
||||
}
|
||||
}
|
||||
|
||||
// Find by existing parent id
|
||||
let parent = null;
|
||||
if (parentPopupId !== null) {
|
||||
parent = this._popups.get(parentPopupId);
|
||||
if (typeof parent !== 'undefined') {
|
||||
const popup = parent.child;
|
||||
if (popup !== null) {
|
||||
return popup;
|
||||
}
|
||||
} else {
|
||||
parent = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Depth
|
||||
if (parent !== null) {
|
||||
if (depth !== null) {
|
||||
throw new Error('Depth cannot be set when parent exists');
|
||||
}
|
||||
depth = parent.depth + 1;
|
||||
} else if (depth === null) {
|
||||
depth = 0;
|
||||
}
|
||||
|
||||
const currentFrameId = this._application.frameId;
|
||||
if (currentFrameId === null) { throw new Error('Cannot create popup: no frameId'); }
|
||||
|
||||
if (popupWindow) {
|
||||
// New unique id
|
||||
if (id === null) {
|
||||
id = generateId(16);
|
||||
}
|
||||
const popup = new PopupWindow(
|
||||
this._application,
|
||||
id,
|
||||
depth,
|
||||
currentFrameId,
|
||||
);
|
||||
this._popups.set(id, popup);
|
||||
return popup;
|
||||
} else if (frameId === currentFrameId) {
|
||||
// New unique id
|
||||
if (id === null) {
|
||||
id = generateId(16);
|
||||
}
|
||||
const popup = new Popup(
|
||||
this._application,
|
||||
id,
|
||||
depth,
|
||||
currentFrameId,
|
||||
childrenSupported,
|
||||
);
|
||||
if (parent !== null) {
|
||||
if (parent.child !== null) {
|
||||
throw new Error('Parent popup already has a child');
|
||||
}
|
||||
popup.parent = /** @type {Popup} */ (parent);
|
||||
parent.child = popup;
|
||||
}
|
||||
this._popups.set(id, popup);
|
||||
popup.prepare();
|
||||
return popup;
|
||||
} else {
|
||||
if (frameId === null) {
|
||||
throw new Error('Invalid frameId');
|
||||
}
|
||||
const useFrameOffsetForwarder = (parentPopupId === null);
|
||||
const info = await this._application.crossFrame.invoke(frameId, 'popupFactoryGetOrCreatePopup', {
|
||||
id,
|
||||
parentPopupId,
|
||||
frameId,
|
||||
childrenSupported,
|
||||
});
|
||||
id = info.id;
|
||||
const popup = new PopupProxy(
|
||||
this._application,
|
||||
id,
|
||||
info.depth,
|
||||
info.frameId,
|
||||
useFrameOffsetForwarder ? this._frameOffsetForwarder : null,
|
||||
);
|
||||
this._popups.set(id, popup);
|
||||
return popup;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force all popups to have a specific visibility value.
|
||||
* @param {boolean} value Whether or not the popups should be visible.
|
||||
* @param {number} priority The priority of the override.
|
||||
* @returns {Promise<import('core').TokenString>} A token which can be passed to clearAllVisibleOverride.
|
||||
* @throws An exception is thrown if any popup fails to have its visibiltiy overridden.
|
||||
*/
|
||||
async setAllVisibleOverride(value, priority) {
|
||||
const promises = [];
|
||||
for (const popup of this._popups.values()) {
|
||||
const promise = this._setPopupVisibleOverrideReturnTuple(popup, value, priority);
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
/** @type {undefined|Error} */
|
||||
let error = void 0;
|
||||
/** @type {{popup: import('popup').PopupAny, token: string}[]} */
|
||||
const results = [];
|
||||
for (const promise of promises) {
|
||||
try {
|
||||
const {popup, token} = await promise;
|
||||
if (token !== null) {
|
||||
results.push({popup, token});
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof error === 'undefined') {
|
||||
error = new Error(`Failed to set popup visibility override: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'undefined') {
|
||||
const token = generateId(16);
|
||||
this._allPopupVisibilityTokenMap.set(token, results);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Revert on error
|
||||
await this._revertPopupVisibilityOverrides(results);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup').PopupAny} popup
|
||||
* @param {boolean} value
|
||||
* @param {number} priority
|
||||
* @returns {Promise<{popup: import('popup').PopupAny, token: ?string}>}
|
||||
*/
|
||||
async _setPopupVisibleOverrideReturnTuple(popup, value, priority) {
|
||||
const token = await popup.setVisibleOverride(value, priority);
|
||||
return {popup, token};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a visibility override that was generated by `setAllVisibleOverride`.
|
||||
* @param {import('core').TokenString} token The token returned from `setAllVisibleOverride`.
|
||||
* @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise.
|
||||
*/
|
||||
async clearAllVisibleOverride(token) {
|
||||
const results = this._allPopupVisibilityTokenMap.get(token);
|
||||
if (typeof results === 'undefined') { return false; }
|
||||
|
||||
this._allPopupVisibilityTokenMap.delete(token);
|
||||
await this._revertPopupVisibilityOverrides(results);
|
||||
return true;
|
||||
}
|
||||
|
||||
// API message handlers
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryGetOrCreatePopup'>} */
|
||||
async _onApiGetOrCreatePopup(details) {
|
||||
const popup = await this.getOrCreatePopup(details);
|
||||
return {
|
||||
id: popup.id,
|
||||
depth: popup.depth,
|
||||
frameId: popup.frameId,
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactorySetOptionsContext'>} */
|
||||
async _onApiSetOptionsContext({id, optionsContext}) {
|
||||
const popup = this._getPopup(id);
|
||||
await popup.setOptionsContext(optionsContext);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryHide'>} */
|
||||
async _onApiHide({id, changeFocus}) {
|
||||
const popup = this._getPopup(id);
|
||||
await popup.hide(changeFocus);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryIsVisible'>} */
|
||||
async _onApiIsVisibleAsync({id}) {
|
||||
const popup = this._getPopup(id);
|
||||
return await popup.isVisible();
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactorySetVisibleOverride'>} */
|
||||
async _onApiSetVisibleOverride({id, value, priority}) {
|
||||
const popup = this._getPopup(id);
|
||||
return await popup.setVisibleOverride(value, priority);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryClearVisibleOverride'>} */
|
||||
async _onApiClearVisibleOverride({id, token}) {
|
||||
const popup = this._getPopup(id);
|
||||
return await popup.clearVisibleOverride(token);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryContainsPoint'>} */
|
||||
async _onApiContainsPoint({id, x, y}) {
|
||||
const popup = this._getPopup(id);
|
||||
const offset = this._getPopupOffset(popup);
|
||||
x += offset.x;
|
||||
y += offset.y;
|
||||
return await popup.containsPoint(x, y);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryShowContent'>} */
|
||||
async _onApiShowContent({id, details, displayDetails}) {
|
||||
const popup = this._getPopup(id);
|
||||
if (!this._popupCanShow(popup)) { return; }
|
||||
|
||||
const offset = this._getPopupOffset(popup);
|
||||
const {sourceRects} = details;
|
||||
for (const sourceRect of sourceRects) {
|
||||
sourceRect.left += offset.x;
|
||||
sourceRect.top += offset.y;
|
||||
sourceRect.right += offset.x;
|
||||
sourceRect.bottom += offset.y;
|
||||
}
|
||||
|
||||
return await popup.showContent(details, displayDetails);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactorySetCustomCss'>} */
|
||||
async _onApiSetCustomCss({id, css}) {
|
||||
const popup = this._getPopup(id);
|
||||
await popup.setCustomCss(css);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryClearAutoPlayTimer'>} */
|
||||
async _onApiClearAutoPlayTimer({id}) {
|
||||
const popup = this._getPopup(id);
|
||||
await popup.clearAutoPlayTimer();
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactorySetContentScale'>} */
|
||||
async _onApiSetContentScale({id, scale}) {
|
||||
const popup = this._getPopup(id);
|
||||
await popup.setContentScale(scale);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryUpdateTheme'>} */
|
||||
async _onApiUpdateTheme({id}) {
|
||||
const popup = this._getPopup(id);
|
||||
await popup.updateTheme();
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactorySetCustomOuterCss'>} */
|
||||
async _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) {
|
||||
const popup = this._getPopup(id);
|
||||
await popup.setCustomOuterCss(css, useWebExtensionApi);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryGetFrameSize'>} */
|
||||
async _onApiGetFrameSize({id}) {
|
||||
const popup = this._getPopup(id);
|
||||
return await popup.getFrameSize();
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactorySetFrameSize'>} */
|
||||
async _onApiSetFrameSize({id, width, height}) {
|
||||
const popup = this._getPopup(id);
|
||||
return await popup.setFrameSize(width, height);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'popupFactoryIsPointerOver'>} */
|
||||
async _onApiIsPointerOver({id}) {
|
||||
const popup = this._getPopup(id);
|
||||
return popup.isPointerOver();
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {import('popup').PopupAny}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_getPopup(id) {
|
||||
const popup = this._popups.get(id);
|
||||
if (typeof popup === 'undefined') {
|
||||
throw new Error(`Invalid popup ID ${id}`);
|
||||
}
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup').PopupAny} popup
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
_getPopupOffset(popup) {
|
||||
const {parent} = popup;
|
||||
if (parent !== null) {
|
||||
const popupRect = parent.getFrameRect();
|
||||
if (popupRect.valid) {
|
||||
return {x: popupRect.left, y: popupRect.top};
|
||||
}
|
||||
}
|
||||
return {x: 0, y: 0};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('popup').PopupAny} popup
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_popupCanShow(popup) {
|
||||
const parent = popup.parent;
|
||||
return parent === null || parent.isVisibleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{popup: import('popup').PopupAny, token: string}[]} overrides
|
||||
* @returns {Promise<boolean[]>}
|
||||
*/
|
||||
async _revertPopupVisibilityOverrides(overrides) {
|
||||
const promises = [];
|
||||
for (const value of overrides) {
|
||||
if (value === null) { continue; }
|
||||
const {popup, token} = value;
|
||||
const promise = popup.clearVisibleOverride(token)
|
||||
.then(
|
||||
(v) => v,
|
||||
() => false,
|
||||
);
|
||||
promises.push(promise);
|
||||
}
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
380
vendor/yomitan/js/app/popup-proxy.js
vendored
Normal file
380
vendor/yomitan/js/app/popup-proxy.js
vendored
Normal file
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
* 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 {log} from '../core/log.js';
|
||||
|
||||
/**
|
||||
* This class is a proxy for a Popup that is hosted in a different frame.
|
||||
* It effectively forwards all API calls to the underlying Popup.
|
||||
* @augments EventDispatcher<import('popup').Events>
|
||||
*/
|
||||
export class PopupProxy extends EventDispatcher {
|
||||
/**
|
||||
* @param {import('../application.js').Application} application The main application instance.
|
||||
* @param {string} id The identifier of the popup.
|
||||
* @param {number} depth The depth of the popup.
|
||||
* @param {number} frameId The frameId of the host frame.
|
||||
* @param {?import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} frameOffsetForwarder A `FrameOffsetForwarder` instance which is used to determine frame positioning.
|
||||
*/
|
||||
constructor(application, id, depth, frameId, frameOffsetForwarder) {
|
||||
super();
|
||||
/** @type {import('../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {string} */
|
||||
this._id = id;
|
||||
/** @type {number} */
|
||||
this._depth = depth;
|
||||
/** @type {number} */
|
||||
this._frameId = frameId;
|
||||
/** @type {?import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} */
|
||||
this._frameOffsetForwarder = frameOffsetForwarder;
|
||||
|
||||
/** @type {number} */
|
||||
this._frameOffsetX = 0;
|
||||
/** @type {number} */
|
||||
this._frameOffsetY = 0;
|
||||
/** @type {?Promise<?[x: number, y: number]>} */
|
||||
this._frameOffsetPromise = null;
|
||||
/** @type {?number} */
|
||||
this._frameOffsetUpdatedAt = null;
|
||||
/** @type {number} */
|
||||
this._frameOffsetExpireTimeout = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the popup.
|
||||
* @type {string}
|
||||
*/
|
||||
get id() {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent of the popup, which is always `null` for `PopupProxy` instances,
|
||||
* since any potential parent popups are in a different frame.
|
||||
* @type {?import('./popup.js').Popup}
|
||||
*/
|
||||
get parent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to set the parent popup.
|
||||
* @param {import('./popup.js').Popup} _value The parent to assign.
|
||||
* @throws {Error} Throws an error, since this class doesn't support a direct parent.
|
||||
*/
|
||||
set parent(_value) {
|
||||
throw new Error('Not supported on PopupProxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* The popup child popup, which is always null for `PopupProxy` instances,
|
||||
* since any potential child popups are in a different frame.
|
||||
* @type {?import('./popup.js').Popup}
|
||||
*/
|
||||
get child() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to set the child popup.
|
||||
* @param {import('./popup.js').Popup} _child The child to assign.
|
||||
* @throws {Error} Throws an error, since this class doesn't support children.
|
||||
*/
|
||||
set child(_child) {
|
||||
throw new Error('Not supported on PopupProxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* The depth of the popup.
|
||||
* @type {number}
|
||||
*/
|
||||
get depth() {
|
||||
return this._depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content window of the frame. This value is null,
|
||||
* since the window is hosted in a different frame.
|
||||
* @type {?Window}
|
||||
*/
|
||||
get frameContentWindow() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the DOM node that contains the frame.
|
||||
* @type {?Element}
|
||||
*/
|
||||
get container() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ID of the frame.
|
||||
* @type {number}
|
||||
*/
|
||||
get frameId() {
|
||||
return this._frameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the options context for the popup.
|
||||
* @param {import('settings').OptionsContext} optionsContext The options context object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setOptionsContext(optionsContext) {
|
||||
await this._invokeSafe('popupFactorySetOptionsContext', {id: this._id, optionsContext}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the popup.
|
||||
* @param {boolean} changeFocus Whether or not the parent popup or host frame should be focused.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async hide(changeFocus) {
|
||||
await this._invokeSafe('popupFactoryHide', {id: this._id, changeFocus}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the popup is currently visible.
|
||||
* @returns {Promise<boolean>} `true` if the popup is visible, `false` otherwise.
|
||||
*/
|
||||
isVisible() {
|
||||
return this._invokeSafe('popupFactoryIsVisible', {id: this._id}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force assigns the visibility of the popup.
|
||||
* @param {boolean} value Whether or not the popup should be visible.
|
||||
* @param {number} priority The priority of the override.
|
||||
* @returns {Promise<?import('core').TokenString>} A token used which can be passed to `clearVisibleOverride`,
|
||||
* or null if the override wasn't assigned.
|
||||
*/
|
||||
setVisibleOverride(value, priority) {
|
||||
return this._invokeSafe('popupFactorySetVisibleOverride', {id: this._id, value, priority}, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a visibility override that was generated by `setVisibleOverride`.
|
||||
* @param {import('core').TokenString} token The token returned from `setVisibleOverride`.
|
||||
* @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise.
|
||||
*/
|
||||
clearVisibleOverride(token) {
|
||||
return this._invokeSafe('popupFactoryClearVisibleOverride', {id: this._id, token}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a point is contained within the popup's rect.
|
||||
* @param {number} x The x coordinate.
|
||||
* @param {number} y The y coordinate.
|
||||
* @returns {Promise<boolean>} `true` if the point is contained within the popup's rect, `false` otherwise.
|
||||
*/
|
||||
async containsPoint(x, y) {
|
||||
if (this._frameOffsetForwarder !== null) {
|
||||
await this._updateFrameOffset();
|
||||
x += this._frameOffsetX;
|
||||
y += this._frameOffsetY;
|
||||
}
|
||||
return await this._invokeSafe('popupFactoryContainsPoint', {id: this._id, x, y}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows and updates the positioning and content of the popup.
|
||||
* @param {import('popup').ContentDetails} details Settings for the outer popup.
|
||||
* @param {?import('display').ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async showContent(details, displayDetails) {
|
||||
if (this._frameOffsetForwarder !== null) {
|
||||
const {sourceRects} = details;
|
||||
await this._updateFrameOffset();
|
||||
for (const sourceRect of sourceRects) {
|
||||
sourceRect.left += this._frameOffsetX;
|
||||
sourceRect.top += this._frameOffsetY;
|
||||
sourceRect.right += this._frameOffsetX;
|
||||
sourceRect.bottom += this._frameOffsetY;
|
||||
}
|
||||
}
|
||||
await this._invokeSafe('popupFactoryShowContent', {id: this._id, details, displayDetails}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the custom styles for the popup content.
|
||||
* @param {string} css The CSS rules.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setCustomCss(css) {
|
||||
await this._invokeSafe('popupFactorySetCustomCss', {id: this._id, css}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the audio auto-play timer, if one has started.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clearAutoPlayTimer() {
|
||||
await this._invokeSafe('popupFactoryClearAutoPlayTimer', {id: this._id}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scaling factor of the popup content.
|
||||
* @param {number} scale The scaling factor.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setContentScale(scale) {
|
||||
await this._invokeSafe('popupFactorySetContentScale', {id: this._id, scale}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the popup is currently visible, synchronously.
|
||||
* @throws An exception is thrown for `PopupProxy` since it cannot synchronously detect visibility.
|
||||
*/
|
||||
isVisibleSync() {
|
||||
throw new Error('Not supported on PopupProxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the outer theme of the popup.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateTheme() {
|
||||
await this._invokeSafe('popupFactoryUpdateTheme', {id: this._id}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the custom styles for the outer popup container.
|
||||
* @param {string} css The CSS rules.
|
||||
* @param {boolean} useWebExtensionApi Whether or not web extension APIs should be used to inject the rules.
|
||||
* When web extension APIs are used, a DOM node is not generated, making it harder to detect the changes.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setCustomOuterCss(css, useWebExtensionApi) {
|
||||
await this._invokeSafe('popupFactorySetCustomOuterCss', {id: this._id, css, useWebExtensionApi}, void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the rectangle of the DOM frame, synchronously.
|
||||
* @returns {import('popup').ValidRect} The rect.
|
||||
* `valid` is `false` for `PopupProxy`, since the DOM node is hosted in a different frame.
|
||||
*/
|
||||
getFrameRect() {
|
||||
return {left: 0, top: 0, right: 0, bottom: 0, valid: false};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the size of the DOM frame.
|
||||
* @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid.
|
||||
*/
|
||||
getFrameSize() {
|
||||
return this._invokeSafe('popupFactoryGetFrameSize', {id: this._id}, {width: 0, height: 0, valid: false});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of the DOM frame.
|
||||
* @param {number} width The desired width of the popup.
|
||||
* @param {number} height The desired height of the popup.
|
||||
* @returns {Promise<boolean>} `true` if the size assignment was successful, `false` otherwise.
|
||||
*/
|
||||
setFrameSize(width, height) {
|
||||
return this._invokeSafe('popupFactorySetFrameSize', {id: this._id, width, height}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the pointer is over this popup.
|
||||
* @returns {Promise<boolean>} Whether the pointer is over the popup
|
||||
*/
|
||||
isPointerOver() {
|
||||
return this._invokeSafe('popupFactoryIsPointerOver', {id: this._id}, false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @template {import('cross-frame-api').ApiNames} TName
|
||||
* @param {TName} action
|
||||
* @param {import('cross-frame-api').ApiParams<TName>} params
|
||||
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
|
||||
*/
|
||||
_invoke(action, params) {
|
||||
return this._application.crossFrame.invoke(this._frameId, action, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('cross-frame-api').ApiNames} TName
|
||||
* @template [TReturnDefault=unknown]
|
||||
* @param {TName} action
|
||||
* @param {import('cross-frame-api').ApiParams<TName>} params
|
||||
* @param {TReturnDefault} defaultReturnValue
|
||||
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>|TReturnDefault>}
|
||||
*/
|
||||
async _invokeSafe(action, params, defaultReturnValue) {
|
||||
try {
|
||||
return await this._invoke(action, params);
|
||||
} catch (e) {
|
||||
if (!this._application.webExtension.unloaded) { throw e; }
|
||||
return defaultReturnValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _updateFrameOffset() {
|
||||
const now = Date.now();
|
||||
const firstRun = this._frameOffsetUpdatedAt === null;
|
||||
const expired = firstRun || /** @type {number} */ (this._frameOffsetUpdatedAt) < now - this._frameOffsetExpireTimeout;
|
||||
if (this._frameOffsetPromise === null && !expired) { return; }
|
||||
|
||||
if (this._frameOffsetPromise !== null) {
|
||||
if (firstRun) {
|
||||
await this._frameOffsetPromise;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = this._updateFrameOffsetInner(now);
|
||||
if (firstRun) {
|
||||
await promise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
*/
|
||||
async _updateFrameOffsetInner(now) {
|
||||
this._frameOffsetPromise = /** @type {import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} */ (this._frameOffsetForwarder).getOffset();
|
||||
try {
|
||||
const offset = await this._frameOffsetPromise;
|
||||
if (offset !== null) {
|
||||
this._frameOffsetX = offset[0];
|
||||
this._frameOffsetY = offset[1];
|
||||
} else {
|
||||
this._frameOffsetX = 0;
|
||||
this._frameOffsetY = 0;
|
||||
this.trigger('offsetNotFound', {});
|
||||
return;
|
||||
}
|
||||
this._frameOffsetUpdatedAt = now;
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
} finally {
|
||||
this._frameOffsetPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
320
vendor/yomitan/js/app/popup-window.js
vendored
Normal file
320
vendor/yomitan/js/app/popup-window.js
vendored
Normal file
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* This class represents a popup that is hosted in a new native window.
|
||||
* @augments EventDispatcher<import('popup').Events>
|
||||
*/
|
||||
export class PopupWindow extends EventDispatcher {
|
||||
/**
|
||||
* @param {import('../application.js').Application} application The main application instance.
|
||||
* @param {string} id The identifier of the popup.
|
||||
* @param {number} depth The depth of the popup.
|
||||
* @param {number} frameId The frameId of the host frame.
|
||||
*/
|
||||
constructor(application, id, depth, frameId) {
|
||||
super();
|
||||
/** @type {import('../application.js').Application} */
|
||||
this._application = application;
|
||||
/** @type {string} */
|
||||
this._id = id;
|
||||
/** @type {number} */
|
||||
this._depth = depth;
|
||||
/** @type {number} */
|
||||
this._frameId = frameId;
|
||||
/** @type {?number} */
|
||||
this._popupTabId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the popup.
|
||||
* @type {string}
|
||||
*/
|
||||
get id() {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {?import('./popup.js').Popup}
|
||||
*/
|
||||
get parent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent of the popup, which is always `null` for `PopupWindow` instances,
|
||||
* since any potential parent popups are in a different frame.
|
||||
* @param {import('./popup.js').Popup} _value The parent to assign.
|
||||
* @throws {Error} Throws an error, since this class doesn't support children.
|
||||
*/
|
||||
set parent(_value) {
|
||||
throw new Error('Not supported on PopupWindow');
|
||||
}
|
||||
|
||||
/**
|
||||
* The popup child popup, which is always null for `PopupWindow` instances,
|
||||
* since any potential child popups are in a different frame.
|
||||
* @type {?import('./popup.js').Popup}
|
||||
*/
|
||||
get child() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to set the child popup.
|
||||
* @param {import('./popup.js').Popup} _value The child to assign.
|
||||
* @throws Throws an error, since this class doesn't support children.
|
||||
*/
|
||||
set child(_value) {
|
||||
throw new Error('Not supported on PopupWindow');
|
||||
}
|
||||
|
||||
/**
|
||||
* The depth of the popup.
|
||||
* @type {number}
|
||||
*/
|
||||
get depth() {
|
||||
return this._depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content window of the frame. This value is null,
|
||||
* since the window is hosted in a different frame.
|
||||
* @type {?Window}
|
||||
*/
|
||||
get frameContentWindow() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the DOM node that contains the frame.
|
||||
* @type {?Element}
|
||||
*/
|
||||
get container() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ID of the frame.
|
||||
* @type {number}
|
||||
*/
|
||||
get frameId() {
|
||||
return this._frameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the options context for the popup.
|
||||
* @param {import('settings').OptionsContext} optionsContext The options context object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setOptionsContext(optionsContext) {
|
||||
await this._invoke(false, 'displaySetOptionsContext', {optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the popup. This does nothing for `PopupWindow`.
|
||||
* @param {boolean} _changeFocus Whether or not the parent popup or host frame should be focused.
|
||||
*/
|
||||
hide(_changeFocus) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the popup is currently visible.
|
||||
* @returns {Promise<boolean>} `true` if the popup is visible, `false` otherwise.
|
||||
*/
|
||||
async isVisible() {
|
||||
return (this._popupTabId !== null && await this._application.api.isTabSearchPopup(this._popupTabId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Force assigns the visibility of the popup.
|
||||
* @param {boolean} _value Whether or not the popup should be visible.
|
||||
* @param {number} _priority The priority of the override.
|
||||
* @returns {Promise<?import('core').TokenString>} A token used which can be passed to `clearVisibleOverride`,
|
||||
* or null if the override wasn't assigned.
|
||||
*/
|
||||
async setVisibleOverride(_value, _priority) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a visibility override that was generated by `setVisibleOverride`.
|
||||
* @param {import('core').TokenString} _token The token returned from `setVisibleOverride`.
|
||||
* @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise.
|
||||
*/
|
||||
async clearVisibleOverride(_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a point is contained within the popup's rect.
|
||||
* @param {number} _x The x coordinate.
|
||||
* @param {number} _y The y coordinate.
|
||||
* @returns {Promise<boolean>} `true` if the point is contained within the popup's rect, `false` otherwise.
|
||||
*/
|
||||
async containsPoint(_x, _y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows and updates the positioning and content of the popup.
|
||||
* @param {import('popup').ContentDetails} _details Settings for the outer popup.
|
||||
* @param {?import('display').ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async showContent(_details, displayDetails) {
|
||||
if (displayDetails === null) { return; }
|
||||
await this._invoke(true, 'displaySetContent', {details: displayDetails});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the custom styles for the popup content.
|
||||
* @param {string} css The CSS rules.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setCustomCss(css) {
|
||||
await this._invoke(false, 'displaySetCustomCss', {css});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the audio auto-play timer, if one has started.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clearAutoPlayTimer() {
|
||||
await this._invoke(false, 'displayAudioClearAutoPlayTimer', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scaling factor of the popup content.
|
||||
* @param {number} _scale The scaling factor.
|
||||
*/
|
||||
async setContentScale(_scale) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the popup is currently visible, synchronously.
|
||||
* @throws An exception is thrown for `PopupWindow` since it cannot synchronously detect visibility.
|
||||
*/
|
||||
isVisibleSync() {
|
||||
throw new Error('Not supported on PopupWindow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the outer theme of the popup.
|
||||
*/
|
||||
async updateTheme() {
|
||||
// NOP
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the custom styles for the outer popup container.
|
||||
* This does nothing for `PopupWindow`.
|
||||
* @param {string} _css The CSS rules.
|
||||
* @param {boolean} _useWebExtensionApi Whether or not web extension APIs should be used to inject the rules.
|
||||
* When web extension APIs are used, a DOM node is not generated, making it harder to detect the changes.
|
||||
*/
|
||||
async setCustomOuterCss(_css, _useWebExtensionApi) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the rectangle of the DOM frame, synchronously.
|
||||
* @returns {import('popup').ValidRect} The rect.
|
||||
* `valid` is `false` for `PopupProxy`, since the DOM node is hosted in a different frame.
|
||||
*/
|
||||
getFrameRect() {
|
||||
return {left: 0, top: 0, right: 0, bottom: 0, valid: false};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the size of the DOM frame.
|
||||
* @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid.
|
||||
*/
|
||||
async getFrameSize() {
|
||||
return {width: 0, height: 0, valid: false};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of the DOM frame.
|
||||
* @param {number} _width The desired width of the popup.
|
||||
* @param {number} _height The desired height of the popup.
|
||||
* @returns {Promise<boolean>} `true` if the size assignment was successful, `false` otherwise.
|
||||
*/
|
||||
async setFrameSize(_width, _height) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isPointerOver() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @template {import('display').DirectApiNames} TName
|
||||
* @param {boolean} open
|
||||
* @param {TName} action
|
||||
* @param {import('display').DirectApiParams<TName>} params
|
||||
* @returns {Promise<import('display').DirectApiReturn<TName>|undefined>}
|
||||
*/
|
||||
async _invoke(open, action, params) {
|
||||
if (this._application.webExtension.unloaded) {
|
||||
return void 0;
|
||||
}
|
||||
|
||||
const message = /** @type {import('display').DirectApiMessageAny} */ ({action, params});
|
||||
|
||||
const frameId = 0;
|
||||
if (this._popupTabId !== null) {
|
||||
try {
|
||||
return /** @type {import('display').DirectApiReturn<TName>} */ (await this._application.crossFrame.invokeTab(
|
||||
this._popupTabId,
|
||||
frameId,
|
||||
'displayPopupMessage2',
|
||||
message,
|
||||
));
|
||||
} catch (e) {
|
||||
if (this._application.webExtension.unloaded) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
this._popupTabId = null;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return void 0;
|
||||
}
|
||||
|
||||
const {tabId} = await this._application.api.getOrCreateSearchPopup({focus: 'ifCreated'});
|
||||
this._popupTabId = tabId;
|
||||
|
||||
return /** @type {import('display').DirectApiReturn<TName>} */ (await this._application.crossFrame.invokeTab(
|
||||
this._popupTabId,
|
||||
frameId,
|
||||
'displayPopupMessage2',
|
||||
message,
|
||||
));
|
||||
}
|
||||
}
|
||||
1249
vendor/yomitan/js/app/popup.js
vendored
Normal file
1249
vendor/yomitan/js/app/popup.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
220
vendor/yomitan/js/app/theme-controller.js
vendored
Normal file
220
vendor/yomitan/js/app/theme-controller.js
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This class is used to control theme attributes on DOM elements.
|
||||
*/
|
||||
export class ThemeController {
|
||||
/**
|
||||
* Creates a new instance of the class.
|
||||
* @param {?HTMLElement} element A DOM element which theme properties are applied to.
|
||||
*/
|
||||
constructor(element) {
|
||||
/** @type {?HTMLElement} */
|
||||
this._element = element;
|
||||
/** @type {import("settings.js").PopupTheme} */
|
||||
this._theme = 'site';
|
||||
/** @type {import("settings.js").PopupOuterTheme} */
|
||||
this._outerTheme = 'site';
|
||||
/** @type {?('dark'|'light')} */
|
||||
this._siteTheme = null;
|
||||
/** @type {'dark'|'light'} */
|
||||
this._browserTheme = 'light';
|
||||
/** @type {boolean} */
|
||||
this.siteOverride = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the DOM element which theme properties are applied to.
|
||||
* @type {?Element}
|
||||
*/
|
||||
get element() {
|
||||
return this._element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the DOM element which theme properties are applied to.
|
||||
* @param {?HTMLElement} value The DOM element to assign.
|
||||
*/
|
||||
set element(value) {
|
||||
this._element = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the main theme for the content.
|
||||
* @type {import("settings.js").PopupTheme}
|
||||
*/
|
||||
get theme() {
|
||||
return this._theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the main theme for the content.
|
||||
* @param {import("settings.js").PopupTheme} value The theme value to assign.
|
||||
*/
|
||||
set theme(value) {
|
||||
this._theme = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the outer theme for the content.
|
||||
* @type {import("settings.js").PopupOuterTheme}
|
||||
*/
|
||||
get outerTheme() {
|
||||
return this._outerTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the outer theme for the content.
|
||||
* @param {import("settings.js").PopupOuterTheme} value The outer theme value to assign.
|
||||
*/
|
||||
set outerTheme(value) {
|
||||
this._outerTheme = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the override value for the site theme.
|
||||
* If this value is `null`, the computed value will be used.
|
||||
* @type {?('dark'|'light')}
|
||||
*/
|
||||
get siteTheme() {
|
||||
return this._siteTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the override value for the site theme.
|
||||
* If this value is `null`, the computed value will be used.
|
||||
* @param {?('dark'|'light')} value The site theme value to assign.
|
||||
*/
|
||||
set siteTheme(value) {
|
||||
this._siteTheme = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the browser's preferred color theme.
|
||||
* The value can be either 'light' or 'dark'.
|
||||
* @type {'dark'|'light'}
|
||||
*/
|
||||
get browserTheme() {
|
||||
return this._browserTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the instance for use and applies the theme settings.
|
||||
*/
|
||||
prepare() {
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQueryList.addEventListener('change', this._onPrefersColorSchemeDarkChange.bind(this));
|
||||
this._onPrefersColorSchemeDarkChange(mediaQueryList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the theme attributes on the target element.
|
||||
* If the site theme value isn't overridden, the current site theme is recomputed.
|
||||
*/
|
||||
updateTheme() {
|
||||
if (this._element === null) { return; }
|
||||
const computedSiteTheme = this._siteTheme !== null ? this._siteTheme : this.computeSiteTheme();
|
||||
const data = this._element.dataset;
|
||||
data.theme = this._resolveThemeValue(this._theme, computedSiteTheme);
|
||||
data.outerTheme = this._resolveThemeValue(this._outerTheme, computedSiteTheme);
|
||||
data.siteTheme = computedSiteTheme;
|
||||
data.browserTheme = this._browserTheme;
|
||||
data.themeRaw = this._theme;
|
||||
data.outerThemeRaw = this._outerTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the current site theme based on the background color.
|
||||
* @returns {'light'|'dark'} The theme of the site.
|
||||
*/
|
||||
computeSiteTheme() {
|
||||
const color = [255, 255, 255];
|
||||
const {documentElement, body} = document;
|
||||
if (documentElement !== null) {
|
||||
this._addColor(color, window.getComputedStyle(documentElement).backgroundColor);
|
||||
}
|
||||
if (body !== null) {
|
||||
this._addColor(color, window.getComputedStyle(body).backgroundColor);
|
||||
}
|
||||
const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
|
||||
return dark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler for when the preferred browser theme changes.
|
||||
* @param {MediaQueryList|MediaQueryListEvent} detail The object containing event details.
|
||||
*/
|
||||
_onPrefersColorSchemeDarkChange({matches}) {
|
||||
this._browserTheme = (matches ? 'dark' : 'light');
|
||||
this.updateTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a settings theme value to the actual value which should be used.
|
||||
* @param {string} theme The theme value to resolve.
|
||||
* @param {string} computedSiteTheme The computed site theme value to use for when the theme value is `'auto'`.
|
||||
* @returns {string} The resolved theme value.
|
||||
*/
|
||||
_resolveThemeValue(theme, computedSiteTheme) {
|
||||
switch (theme) {
|
||||
case 'site': return this.siteOverride ? this._browserTheme : computedSiteTheme;
|
||||
case 'browser': return this._browserTheme;
|
||||
default: return theme;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the value of a CSS color to an accumulation target.
|
||||
* @param {number[]} target The target color buffer to accumulate into, as an array of [r, g, b].
|
||||
* @param {string|*} cssColor The CSS color value to add to the target. If this value is not a string,
|
||||
* the target will not be modified.
|
||||
*/
|
||||
_addColor(target, cssColor) {
|
||||
if (typeof cssColor !== 'string') { return; }
|
||||
|
||||
const color = this._getColorInfo(cssColor);
|
||||
if (color === null) { return; }
|
||||
|
||||
const a = color[3];
|
||||
if (a <= 0) { return; }
|
||||
|
||||
const aInv = 1 - a;
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
target[i] = target[i] * aInv + color[i] * a;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decomposes a CSS color string into its RGBA values.
|
||||
* @param {string} cssColor The color value to decompose. This value is expected to be in the form RGB(r, g, b) or RGBA(r, g, b, a).
|
||||
* @returns {?number[]} The color and alpha values as [r, g, b, a]. The color component values range from [0, 255], and the alpha ranges from [0, 1].
|
||||
*/
|
||||
_getColorInfo(cssColor) {
|
||||
const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor);
|
||||
if (m === null) { return null; }
|
||||
|
||||
const m4 = m[4];
|
||||
return [
|
||||
Number.parseInt(m[1], 10),
|
||||
Number.parseInt(m[2], 10),
|
||||
Number.parseInt(m[3], 10),
|
||||
m4 ? Math.max(0, Math.min(1, Number.parseFloat(m4))) : 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
293
vendor/yomitan/js/application.js
vendored
Normal file
293
vendor/yomitan/js/application.js
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* 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 {API} from './comm/api.js';
|
||||
import {CrossFrameAPI} from './comm/cross-frame-api.js';
|
||||
import {createApiMap, invokeApiMapHandler} from './core/api-map.js';
|
||||
import {EventDispatcher} from './core/event-dispatcher.js';
|
||||
import {ExtensionError} from './core/extension-error.js';
|
||||
import {log} from './core/log.js';
|
||||
import {deferPromise} from './core/utilities.js';
|
||||
import {WebExtension} from './extension/web-extension.js';
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkChromeNotAvailable() {
|
||||
let hasChrome = false;
|
||||
let hasBrowser = false;
|
||||
try {
|
||||
hasChrome = (typeof chrome === 'object' && chrome !== null && typeof chrome.runtime !== 'undefined');
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
try {
|
||||
hasBrowser = (typeof browser === 'object' && browser !== null && typeof browser.runtime !== 'undefined');
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
return (hasBrowser && !hasChrome);
|
||||
}
|
||||
|
||||
// Set up chrome alias if it's not available (Edge Legacy)
|
||||
if (checkChromeNotAvailable()) {
|
||||
// @ts-expect-error - objects should have roughly the same interface
|
||||
// eslint-disable-next-line no-global-assign
|
||||
chrome = browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WebExtension} webExtension
|
||||
*/
|
||||
async function waitForBackendReady(webExtension) {
|
||||
const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise());
|
||||
/** @type {import('application').ApiMap} */
|
||||
const apiMap = createApiMap([['applicationBackendReady', () => { resolve(); }]]);
|
||||
/** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */
|
||||
const onMessage = ({action, params}, _sender, callback) => invokeApiMapHandler(apiMap, action, params, [], callback);
|
||||
chrome.runtime.onMessage.addListener(onMessage);
|
||||
try {
|
||||
await webExtension.sendMessagePromise({action: 'requestBackendReadySignal'});
|
||||
await promise;
|
||||
} finally {
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function waitForDomContentLoaded() {
|
||||
return new Promise((resolve) => {
|
||||
if (document.readyState !== 'loading') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onDomContentLoaded = () => {
|
||||
document.removeEventListener('DOMContentLoaded', onDomContentLoaded);
|
||||
resolve();
|
||||
};
|
||||
document.addEventListener('DOMContentLoaded', onDomContentLoaded);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Yomitan class is a core component through which various APIs are handled and invoked.
|
||||
* @augments EventDispatcher<import('application').Events>
|
||||
*/
|
||||
export class Application extends EventDispatcher {
|
||||
/**
|
||||
* Creates a new instance. The instance should not be used until it has been fully prepare()'d.
|
||||
* @param {API} api
|
||||
* @param {CrossFrameAPI} crossFrameApi
|
||||
*/
|
||||
constructor(api, crossFrameApi) {
|
||||
super();
|
||||
/** @type {WebExtension} */
|
||||
this._webExtension = new WebExtension();
|
||||
/** @type {?boolean} */
|
||||
this._isBackground = null;
|
||||
/** @type {API} */
|
||||
this._api = api;
|
||||
/** @type {CrossFrameAPI} */
|
||||
this._crossFrame = crossFrameApi;
|
||||
/** @type {boolean} */
|
||||
this._isReady = false;
|
||||
/* eslint-disable @stylistic/no-multi-spaces */
|
||||
/** @type {import('application').ApiMap} */
|
||||
this._apiMap = createApiMap([
|
||||
['applicationIsReady', this._onMessageIsReady.bind(this)],
|
||||
['applicationGetUrl', this._onMessageGetUrl.bind(this)],
|
||||
['applicationOptionsUpdated', this._onMessageOptionsUpdated.bind(this)],
|
||||
['applicationDatabaseUpdated', this._onMessageDatabaseUpdated.bind(this)],
|
||||
['applicationZoomChanged', this._onMessageZoomChanged.bind(this)],
|
||||
]);
|
||||
/* eslint-enable @stylistic/no-multi-spaces */
|
||||
}
|
||||
|
||||
/** @type {WebExtension} */
|
||||
get webExtension() {
|
||||
return this._webExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the API instance for communicating with the backend.
|
||||
* This value will be null on the background page/service worker.
|
||||
* @type {API}
|
||||
*/
|
||||
get api() {
|
||||
return this._api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the CrossFrameAPI instance for communicating with different frames.
|
||||
* This value will be null on the background page/service worker.
|
||||
* @type {CrossFrameAPI}
|
||||
*/
|
||||
get crossFrame() {
|
||||
return this._crossFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {?number}
|
||||
*/
|
||||
get tabId() {
|
||||
return this._crossFrame.tabId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {?number}
|
||||
*/
|
||||
get frameId() {
|
||||
return this._crossFrame.frameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the instance for use.
|
||||
*/
|
||||
prepare() {
|
||||
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
||||
log.on('logGenericError', this._onLogGenericError.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the backend indicating that the frame is ready and all script
|
||||
* setup has completed.
|
||||
*/
|
||||
ready() {
|
||||
if (this._isReady) { return; }
|
||||
this._isReady = true;
|
||||
void this._webExtension.sendMessagePromise({action: 'applicationReady'});
|
||||
}
|
||||
|
||||
/** */
|
||||
triggerStorageChanged() {
|
||||
this.trigger('storageChanged', {});
|
||||
}
|
||||
|
||||
/** */
|
||||
triggerClosePopups() {
|
||||
this.trigger('closePopups', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} waitForDom
|
||||
* @param {(application: Application) => (Promise<void>)} mainFunction
|
||||
*/
|
||||
static async main(waitForDom, mainFunction) {
|
||||
const supportsServiceWorker = 'serviceWorker' in navigator; // Basically, all browsers except Firefox. But it's possible Firefox will support it in the future, so we check in this fashion to be future-proof.
|
||||
const inExtensionContext = window.location.protocol === new URL(import.meta.url).protocol; // This code runs both in content script as well as in the iframe, so we need to differentiate the situation
|
||||
/** @type {MessagePort | null} */
|
||||
// If this is Firefox, we don't have a service worker and can't postMessage,
|
||||
// so we temporarily create a SharedWorker in order to establish a MessageChannel
|
||||
// which we can use to postMessage with the backend.
|
||||
// This can only be done in the extension context (aka iframe within popup),
|
||||
// not in the content script context.
|
||||
const backendPort = !supportsServiceWorker && inExtensionContext ?
|
||||
(() => {
|
||||
const sharedWorkerBridge = new SharedWorker(new URL('comm/shared-worker-bridge.js', import.meta.url), {type: 'module'});
|
||||
const backendChannel = new MessageChannel();
|
||||
sharedWorkerBridge.port.postMessage({action: 'connectToBackend1'}, [backendChannel.port1]);
|
||||
sharedWorkerBridge.port.close();
|
||||
return backendChannel.port2;
|
||||
})() :
|
||||
null;
|
||||
|
||||
const webExtension = new WebExtension();
|
||||
log.configure(webExtension.extensionName);
|
||||
|
||||
const mediaDrawingWorkerToBackendChannel = new MessageChannel();
|
||||
const mediaDrawingWorker = inExtensionContext ? new Worker(new URL('display/media-drawing-worker.js', import.meta.url), {type: 'module'}) : null;
|
||||
mediaDrawingWorker?.postMessage({action: 'connectToDatabaseWorker'}, [mediaDrawingWorkerToBackendChannel.port2]);
|
||||
|
||||
const api = new API(webExtension, mediaDrawingWorker, backendPort);
|
||||
await waitForBackendReady(webExtension);
|
||||
if (mediaDrawingWorker !== null) {
|
||||
api.connectToDatabaseWorker(mediaDrawingWorkerToBackendChannel.port1);
|
||||
}
|
||||
setInterval(() => {
|
||||
void api.heartbeat();
|
||||
}, 20 * 1000);
|
||||
|
||||
const {tabId, frameId} = await api.frameInformationGet();
|
||||
const crossFrameApi = new CrossFrameAPI(api, tabId, frameId);
|
||||
crossFrameApi.prepare();
|
||||
const application = new Application(api, crossFrameApi);
|
||||
application.prepare();
|
||||
if (waitForDom) { await waitForDomContentLoaded(); }
|
||||
try {
|
||||
await mainFunction(application);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
} finally {
|
||||
application.ready();
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
_getUrl() {
|
||||
return location.href;
|
||||
}
|
||||
|
||||
/** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */
|
||||
_onMessage({action, params}, _sender, callback) {
|
||||
return invokeApiMapHandler(this._apiMap, action, params, [], callback);
|
||||
}
|
||||
|
||||
/** @type {import('application').ApiHandler<'applicationIsReady'>} */
|
||||
_onMessageIsReady() {
|
||||
return this._isReady;
|
||||
}
|
||||
|
||||
/** @type {import('application').ApiHandler<'applicationGetUrl'>} */
|
||||
_onMessageGetUrl() {
|
||||
return {url: this._getUrl()};
|
||||
}
|
||||
|
||||
/** @type {import('application').ApiHandler<'applicationOptionsUpdated'>} */
|
||||
_onMessageOptionsUpdated({source}) {
|
||||
if (source !== 'background') {
|
||||
this.trigger('optionsUpdated', {source});
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('application').ApiHandler<'applicationDatabaseUpdated'>} */
|
||||
_onMessageDatabaseUpdated({type, cause}) {
|
||||
this.trigger('databaseUpdated', {type, cause});
|
||||
}
|
||||
|
||||
/** @type {import('application').ApiHandler<'applicationZoomChanged'>} */
|
||||
_onMessageZoomChanged({oldZoomFactor, newZoomFactor}) {
|
||||
this.trigger('zoomChanged', {oldZoomFactor, newZoomFactor});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('log').Events['logGenericError']} params
|
||||
*/
|
||||
async _onLogGenericError({error, level, context}) {
|
||||
try {
|
||||
await this._api.logGenericErrorBackend(ExtensionError.serialize(error), level, context);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
}
|
||||
2982
vendor/yomitan/js/background/backend.js
vendored
Normal file
2982
vendor/yomitan/js/background/backend.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
32
vendor/yomitan/js/background/background-main.js
vendored
Normal file
32
vendor/yomitan/js/background/background-main.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {log} from '../core/log.js';
|
||||
import {WebExtension} from '../extension/web-extension.js';
|
||||
import {Backend} from './backend.js';
|
||||
|
||||
/** Entry point. */
|
||||
async function main() {
|
||||
const webExtension = new WebExtension();
|
||||
log.configure(webExtension.extensionName);
|
||||
|
||||
const backend = new Backend(webExtension);
|
||||
await backend.prepare();
|
||||
}
|
||||
|
||||
void main();
|
||||
27
vendor/yomitan/js/background/offscreen-main.js
vendored
Normal file
27
vendor/yomitan/js/background/offscreen-main.js
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 {Offscreen} from './offscreen.js';
|
||||
|
||||
/** Entry point. */
|
||||
function main() {
|
||||
const offscreen = new Offscreen();
|
||||
offscreen.prepare();
|
||||
}
|
||||
|
||||
main();
|
||||
318
vendor/yomitan/js/background/offscreen-proxy.js
vendored
Normal file
318
vendor/yomitan/js/background/offscreen-proxy.js
vendored
Normal file
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2016-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 {isObjectNotArray} from '../core/object-utilities.js';
|
||||
import {base64ToArrayBuffer} from '../data/array-buffer-util.js';
|
||||
|
||||
/**
|
||||
* This class is responsible for creating and communicating with an offscreen document.
|
||||
* This offscreen document is used to solve three issues:
|
||||
*
|
||||
* - Provide clipboard access for the `ClipboardReader` class in the context of a MV3 extension.
|
||||
* The background service workers doesn't have access a webpage to read the clipboard from,
|
||||
* so it must be done in the offscreen page.
|
||||
*
|
||||
* - Create a worker for image rendering, which both selects the images from the database,
|
||||
* decodes/rasterizes them, and then sends (= postMessage transfers) them back to a worker
|
||||
* in the popup to be rendered onto OffscreenCanvas.
|
||||
*
|
||||
* - Provide a longer lifetime for the dictionary database. The background service worker can be
|
||||
* terminated by the web browser, which means that when it restarts, it has to go through its
|
||||
* initialization process again. This initialization process can take a non-trivial amount of
|
||||
* time, which is primarily caused by the startup of the IndexedDB database, especially when a
|
||||
* large amount of dictionary data is installed.
|
||||
*
|
||||
* The offscreen document stays alive longer, potentially forever, which may be an artifact of
|
||||
* the clipboard access it requests in the `reasons` parameter. Therefore, this initialization
|
||||
* process should only take place once, or at the very least, less frequently than the service
|
||||
* worker.
|
||||
*
|
||||
* The long lifetime of the offscreen document is not guaranteed by the spec, which could
|
||||
* result in this code functioning poorly in the future if a web browser vendor changes the
|
||||
* APIs or the implementation substantially, and this is even referenced on the Chrome
|
||||
* developer website.
|
||||
* @see https://developer.chrome.com/blog/Offscreen-Documents-in-Manifest-v3
|
||||
* @see https://developer.chrome.com/docs/extensions/reference/api/offscreen
|
||||
*/
|
||||
export class OffscreenProxy {
|
||||
/**
|
||||
* @param {import('../extension/web-extension.js').WebExtension} webExtension
|
||||
*/
|
||||
constructor(webExtension) {
|
||||
/** @type {import('../extension/web-extension.js').WebExtension} */
|
||||
this._webExtension = webExtension;
|
||||
/** @type {?Promise<void>} */
|
||||
this._creatingOffscreen = null;
|
||||
|
||||
/** @type {?MessagePort} */
|
||||
this._currentOffscreenPort = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.chrome.com/docs/extensions/reference/offscreen/
|
||||
*/
|
||||
async prepare() {
|
||||
if (await this._hasOffscreenDocument()) {
|
||||
void this.sendMessagePromise({action: 'createAndRegisterPortOffscreen'});
|
||||
return;
|
||||
}
|
||||
if (this._creatingOffscreen) {
|
||||
await this._creatingOffscreen;
|
||||
return;
|
||||
}
|
||||
|
||||
this._creatingOffscreen = chrome.offscreen.createDocument({
|
||||
url: 'offscreen.html',
|
||||
reasons: [
|
||||
/** @type {chrome.offscreen.Reason} */ ('CLIPBOARD'),
|
||||
],
|
||||
justification: 'Access to the clipboard',
|
||||
});
|
||||
await this._creatingOffscreen;
|
||||
this._creatingOffscreen = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _hasOffscreenDocument() {
|
||||
const offscreenUrl = chrome.runtime.getURL('offscreen.html');
|
||||
if (!chrome.runtime.getContexts) { // Chrome version below 116
|
||||
// Clients: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/clients
|
||||
// @ts-expect-error - Types not set up for service workers yet
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const matchedClients = await clients.matchAll();
|
||||
// @ts-expect-error - Types not set up for service workers yet
|
||||
return await matchedClients.some((client) => client.url === offscreenUrl);
|
||||
}
|
||||
|
||||
const contexts = await chrome.runtime.getContexts({
|
||||
contextTypes: [
|
||||
/** @type {chrome.runtime.ContextType} */ ('OFFSCREEN_DOCUMENT'),
|
||||
],
|
||||
documentUrls: [offscreenUrl],
|
||||
});
|
||||
return contexts.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('offscreen').ApiNames} TMessageType
|
||||
* @param {import('offscreen').ApiMessage<TMessageType>} message
|
||||
* @returns {Promise<import('offscreen').ApiReturn<TMessageType>>}
|
||||
*/
|
||||
async sendMessagePromise(message) {
|
||||
const response = await this._webExtension.sendMessagePromise(message);
|
||||
return this._getMessageResponseResult(/** @type {import('core').Response<import('offscreen').ApiReturn<TMessageType>>} */ (response));
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TReturn=unknown]
|
||||
* @param {import('core').Response<TReturn>} response
|
||||
* @returns {TReturn}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_getMessageResponseResult(response) {
|
||||
const runtimeError = chrome.runtime.lastError;
|
||||
if (typeof runtimeError !== 'undefined') {
|
||||
throw new Error(runtimeError.message);
|
||||
}
|
||||
if (!isObjectNotArray(response)) {
|
||||
throw new Error('Offscreen document did not respond');
|
||||
}
|
||||
const responseError = response.error;
|
||||
if (responseError) {
|
||||
throw ExtensionError.deserialize(responseError);
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessagePort} port
|
||||
*/
|
||||
async registerOffscreenPort(port) {
|
||||
if (this._currentOffscreenPort) {
|
||||
this._currentOffscreenPort.close();
|
||||
}
|
||||
this._currentOffscreenPort = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* When you need to transfer Transferable objects, you can use this method which uses postMessage over the MessageChannel port established with the offscreen document.
|
||||
* @template {import('offscreen').McApiNames} TMessageType
|
||||
* @param {import('offscreen').McApiMessage<TMessageType>} message
|
||||
* @param {Transferable[]} transfers
|
||||
*/
|
||||
sendMessageViaPort(message, transfers) {
|
||||
if (this._currentOffscreenPort !== null) {
|
||||
this._currentOffscreenPort.postMessage(message, transfers);
|
||||
} else {
|
||||
void this.sendMessagePromise({action: 'createAndRegisterPortOffscreen'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DictionaryDatabaseProxy {
|
||||
/**
|
||||
* @param {OffscreenProxy} offscreen
|
||||
*/
|
||||
constructor(offscreen) {
|
||||
/** @type {OffscreenProxy} */
|
||||
this._offscreen = offscreen;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async prepare() {
|
||||
await this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('dictionary-importer').Summary[]>}
|
||||
*/
|
||||
async getDictionaryInfo() {
|
||||
return this._offscreen.sendMessagePromise({action: 'getDictionaryInfoOffscreen'});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async purge() {
|
||||
return await this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').MediaRequest[]} targets
|
||||
* @returns {Promise<import('dictionary-database').Media[]>}
|
||||
*/
|
||||
async getMedia(targets) {
|
||||
const serializedMedia = /** @type {import('dictionary-database').Media<string>[]} */ (await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}}));
|
||||
return serializedMedia.map((m) => ({...m, content: base64ToArrayBuffer(m.content)}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessagePort} port
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async connectToDatabaseWorker(port) {
|
||||
this._offscreen.sendMessageViaPort({action: 'connectToDatabaseWorker'}, [port]);
|
||||
}
|
||||
}
|
||||
|
||||
export class TranslatorProxy {
|
||||
/**
|
||||
* @param {OffscreenProxy} offscreen
|
||||
*/
|
||||
constructor(offscreen) {
|
||||
/** @type {OffscreenProxy} */
|
||||
this._offscreen = offscreen;
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
await this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen'});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import('translation').FindKanjiOptions} options
|
||||
* @returns {Promise<import('dictionary').KanjiDictionaryEntry[]>}
|
||||
*/
|
||||
async findKanji(text, options) {
|
||||
const enabledDictionaryMapList = [...options.enabledDictionaryMap];
|
||||
/** @type {import('offscreen').FindKanjiOptionsOffscreen} */
|
||||
const modifiedOptions = {
|
||||
...options,
|
||||
enabledDictionaryMap: enabledDictionaryMapList,
|
||||
};
|
||||
return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, options: modifiedOptions}});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('translator').FindTermsMode} mode
|
||||
* @param {string} text
|
||||
* @param {import('translation').FindTermsOptions} options
|
||||
* @returns {Promise<import('translator').FindTermsResult>}
|
||||
*/
|
||||
async findTerms(mode, text, options) {
|
||||
const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = options;
|
||||
const enabledDictionaryMapList = [...enabledDictionaryMap];
|
||||
const excludeDictionaryDefinitionsList = excludeDictionaryDefinitions ? [...excludeDictionaryDefinitions] : null;
|
||||
const textReplacementsSerialized = textReplacements.map((group) => {
|
||||
return group !== null ? group.map((opt) => ({...opt, pattern: opt.pattern.toString()})) : null;
|
||||
});
|
||||
/** @type {import('offscreen').FindTermsOptionsOffscreen} */
|
||||
const modifiedOptions = {
|
||||
...options,
|
||||
enabledDictionaryMap: enabledDictionaryMapList,
|
||||
excludeDictionaryDefinitions: excludeDictionaryDefinitionsList,
|
||||
textReplacements: textReplacementsSerialized,
|
||||
};
|
||||
return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, options: modifiedOptions}});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('translator').TermReadingList} termReadingList
|
||||
* @param {string[]} dictionaries
|
||||
* @returns {Promise<import('translator').TermFrequencySimple[]>}
|
||||
*/
|
||||
async getTermFrequencies(termReadingList, dictionaries) {
|
||||
return this._offscreen.sendMessagePromise({action: 'getTermFrequenciesOffscreen', params: {termReadingList, dictionaries}});
|
||||
}
|
||||
|
||||
/** */
|
||||
async clearDatabaseCaches() {
|
||||
await this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'});
|
||||
}
|
||||
}
|
||||
|
||||
export class ClipboardReaderProxy {
|
||||
/**
|
||||
* @param {OffscreenProxy} offscreen
|
||||
*/
|
||||
constructor(offscreen) {
|
||||
/** @type {?import('environment').Browser} */
|
||||
this._browser = null;
|
||||
/** @type {OffscreenProxy} */
|
||||
this._offscreen = offscreen;
|
||||
}
|
||||
|
||||
/** @type {?import('environment').Browser} */
|
||||
get browser() { return this._browser; }
|
||||
set browser(value) {
|
||||
if (this._browser === value) { return; }
|
||||
this._browser = value;
|
||||
void this._offscreen.sendMessagePromise({action: 'clipboardSetBrowserOffscreen', params: {value}});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} useRichText
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getText(useRichText) {
|
||||
return await this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<?string>}
|
||||
*/
|
||||
async getImage() {
|
||||
return await this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'});
|
||||
}
|
||||
}
|
||||
224
vendor/yomitan/js/background/offscreen.js
vendored
Normal file
224
vendor/yomitan/js/background/offscreen.js
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2016-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 {API} from '../comm/api.js';
|
||||
import {ClipboardReader} from '../comm/clipboard-reader.js';
|
||||
import {createApiMap, invokeApiMapHandler} from '../core/api-map.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
import {log} from '../core/log.js';
|
||||
import {sanitizeCSS} from '../core/utilities.js';
|
||||
import {arrayBufferToBase64} from '../data/array-buffer-util.js';
|
||||
import {DictionaryDatabase} from '../dictionary/dictionary-database.js';
|
||||
import {WebExtension} from '../extension/web-extension.js';
|
||||
import {Translator} from '../language/translator.js';
|
||||
|
||||
/**
|
||||
* This class controls the core logic of the extension, including API calls
|
||||
* and various forms of communication between browser tabs and external applications.
|
||||
*/
|
||||
export class Offscreen {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {DictionaryDatabase} */
|
||||
this._dictionaryDatabase = new DictionaryDatabase();
|
||||
/** @type {Translator} */
|
||||
this._translator = new Translator(this._dictionaryDatabase);
|
||||
/** @type {ClipboardReader} */
|
||||
this._clipboardReader = new ClipboardReader(
|
||||
(typeof document === 'object' && document !== null ? document : null),
|
||||
'#clipboard-paste-target',
|
||||
'#clipboard-rich-content-paste-target',
|
||||
);
|
||||
|
||||
/* eslint-disable @stylistic/no-multi-spaces */
|
||||
/** @type {import('offscreen').ApiMap} */
|
||||
this._apiMap = createApiMap([
|
||||
['clipboardGetTextOffscreen', this._getTextHandler.bind(this)],
|
||||
['clipboardGetImageOffscreen', this._getImageHandler.bind(this)],
|
||||
['clipboardSetBrowserOffscreen', this._setClipboardBrowser.bind(this)],
|
||||
['databasePrepareOffscreen', this._prepareDatabaseHandler.bind(this)],
|
||||
['getDictionaryInfoOffscreen', this._getDictionaryInfoHandler.bind(this)],
|
||||
['databasePurgeOffscreen', this._purgeDatabaseHandler.bind(this)],
|
||||
['databaseGetMediaOffscreen', this._getMediaHandler.bind(this)],
|
||||
['translatorPrepareOffscreen', this._prepareTranslatorHandler.bind(this)],
|
||||
['findKanjiOffscreen', this._findKanjiHandler.bind(this)],
|
||||
['findTermsOffscreen', this._findTermsHandler.bind(this)],
|
||||
['getTermFrequenciesOffscreen', this._getTermFrequenciesHandler.bind(this)],
|
||||
['clearDatabaseCachesOffscreen', this._clearDatabaseCachesHandler.bind(this)],
|
||||
['createAndRegisterPortOffscreen', this._createAndRegisterPort.bind(this)],
|
||||
['sanitizeCSSOffscreen', this._sanitizeCSSOffscreen.bind(this)],
|
||||
]);
|
||||
/* eslint-enable @stylistic/no-multi-spaces */
|
||||
|
||||
/** @type {import('offscreen').McApiMap} */
|
||||
this._mcApiMap = createApiMap([
|
||||
['connectToDatabaseWorker', this._connectToDatabaseWorkerHandler.bind(this)],
|
||||
]);
|
||||
|
||||
/** @type {?Promise<void>} */
|
||||
this._prepareDatabasePromise = null;
|
||||
|
||||
/**
|
||||
* @type {API}
|
||||
*/
|
||||
this._api = new API(new WebExtension());
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
||||
navigator.serviceWorker.addEventListener('controllerchange', this._createAndRegisterPort.bind(this));
|
||||
this._createAndRegisterPort();
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'clipboardGetTextOffscreen'>} */
|
||||
async _getTextHandler({useRichText}) {
|
||||
return await this._clipboardReader.getText(useRichText);
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'clipboardGetImageOffscreen'>} */
|
||||
async _getImageHandler() {
|
||||
return await this._clipboardReader.getImage();
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'clipboardSetBrowserOffscreen'>} */
|
||||
_setClipboardBrowser({value}) {
|
||||
this._clipboardReader.browser = value;
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'databasePrepareOffscreen'>} */
|
||||
_prepareDatabaseHandler() {
|
||||
if (this._prepareDatabasePromise !== null) {
|
||||
return this._prepareDatabasePromise;
|
||||
}
|
||||
this._prepareDatabasePromise = this._dictionaryDatabase.prepare();
|
||||
return this._prepareDatabasePromise;
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'getDictionaryInfoOffscreen'>} */
|
||||
async _getDictionaryInfoHandler() {
|
||||
return await this._dictionaryDatabase.getDictionaryInfo();
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'databasePurgeOffscreen'>} */
|
||||
async _purgeDatabaseHandler() {
|
||||
return await this._dictionaryDatabase.purge();
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'databaseGetMediaOffscreen'>} */
|
||||
async _getMediaHandler({targets}) {
|
||||
const media = await this._dictionaryDatabase.getMedia(targets);
|
||||
return media.map((m) => ({...m, content: arrayBufferToBase64(m.content)}));
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'translatorPrepareOffscreen'>} */
|
||||
_prepareTranslatorHandler() {
|
||||
this._translator.prepare();
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'findKanjiOffscreen'>} */
|
||||
async _findKanjiHandler({text, options}) {
|
||||
/** @type {import('translation').FindKanjiOptions} */
|
||||
const modifiedOptions = {
|
||||
...options,
|
||||
enabledDictionaryMap: new Map(options.enabledDictionaryMap),
|
||||
};
|
||||
return await this._translator.findKanji(text, modifiedOptions);
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'findTermsOffscreen'>} */
|
||||
async _findTermsHandler({mode, text, options}) {
|
||||
const enabledDictionaryMap = new Map(options.enabledDictionaryMap);
|
||||
const excludeDictionaryDefinitions = (
|
||||
options.excludeDictionaryDefinitions !== null ?
|
||||
new Set(options.excludeDictionaryDefinitions) :
|
||||
null
|
||||
);
|
||||
const textReplacements = options.textReplacements.map((group) => {
|
||||
if (group === null) { return null; }
|
||||
return group.map((opt) => {
|
||||
// https://stackoverflow.com/a/33642463
|
||||
const match = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i);
|
||||
const [, pattern, flags] = match !== null ? match : ['', '', ''];
|
||||
return {...opt, pattern: new RegExp(pattern, flags ?? '')};
|
||||
});
|
||||
});
|
||||
/** @type {import('translation').FindTermsOptions} */
|
||||
const modifiedOptions = {
|
||||
...options,
|
||||
enabledDictionaryMap,
|
||||
excludeDictionaryDefinitions,
|
||||
textReplacements,
|
||||
};
|
||||
return this._translator.findTerms(mode, text, modifiedOptions);
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'getTermFrequenciesOffscreen'>} */
|
||||
_getTermFrequenciesHandler({termReadingList, dictionaries}) {
|
||||
return this._translator.getTermFrequencies(termReadingList, dictionaries);
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'clearDatabaseCachesOffscreen'>} */
|
||||
_clearDatabaseCachesHandler() {
|
||||
this._translator.clearDatabaseCaches();
|
||||
}
|
||||
|
||||
/** @type {import('extension').ChromeRuntimeOnMessageCallback<import('offscreen').ApiMessageAny>} */
|
||||
_onMessage({action, params}, _sender, callback) {
|
||||
return invokeApiMapHandler(this._apiMap, action, params, [], callback);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
_createAndRegisterPort() {
|
||||
const mc = new MessageChannel();
|
||||
mc.port1.onmessage = this._onMcMessage.bind(this);
|
||||
mc.port1.onmessageerror = this._onMcMessageError.bind(this);
|
||||
this._api.registerOffscreenPort([mc.port2]);
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').McApiHandler<'connectToDatabaseWorker'>} */
|
||||
async _connectToDatabaseWorkerHandler(_params, ports) {
|
||||
await this._dictionaryDatabase.connectToDatabaseWorker(ports[0]);
|
||||
}
|
||||
|
||||
/** @type {import('offscreen').ApiHandler<'sanitizeCSSOffscreen'>} */
|
||||
_sanitizeCSSOffscreen(params) {
|
||||
return sanitizeCSS(params.css);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<import('offscreen').McApiMessageAny>} event
|
||||
*/
|
||||
_onMcMessage(event) {
|
||||
const {action, params} = event.data;
|
||||
invokeApiMapHandler(this._mcApiMap, action, params, [event.ports], () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<import('offscreen').McApiMessageAny>} event
|
||||
*/
|
||||
_onMcMessageError(event) {
|
||||
const error = new ExtensionError('Offscreen: Error receiving message via postMessage');
|
||||
error.data = event;
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
386
vendor/yomitan/js/background/profile-conditions-util.js
vendored
Normal file
386
vendor/yomitan/js/background/profile-conditions-util.js
vendored
Normal file
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* 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 {JsonSchema} from '../data/json-schema.js';
|
||||
|
||||
/** @type {RegExp} */
|
||||
const splitPattern = /[,;\s]+/;
|
||||
/** @type {Map<string, {operators: Map<string, import('profile-conditions-util').CreateSchemaFunction>}>} */
|
||||
const descriptors = new Map([
|
||||
[
|
||||
'popupLevel',
|
||||
{
|
||||
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
|
||||
['equal', createSchemaPopupLevelEqual.bind(this)],
|
||||
['notEqual', createSchemaPopupLevelNotEqual.bind(this)],
|
||||
['lessThan', createSchemaPopupLevelLessThan.bind(this)],
|
||||
['greaterThan', createSchemaPopupLevelGreaterThan.bind(this)],
|
||||
['lessThanOrEqual', createSchemaPopupLevelLessThanOrEqual.bind(this)],
|
||||
['greaterThanOrEqual', createSchemaPopupLevelGreaterThanOrEqual.bind(this)],
|
||||
])),
|
||||
},
|
||||
],
|
||||
[
|
||||
'url',
|
||||
{
|
||||
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
|
||||
['matchDomain', createSchemaUrlMatchDomain.bind(this)],
|
||||
['matchRegExp', createSchemaUrlMatchRegExp.bind(this)],
|
||||
])),
|
||||
},
|
||||
],
|
||||
[
|
||||
'modifierKeys',
|
||||
{
|
||||
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
|
||||
['are', createSchemaModifierKeysAre.bind(this)],
|
||||
['areNot', createSchemaModifierKeysAreNot.bind(this)],
|
||||
['include', createSchemaModifierKeysInclude.bind(this)],
|
||||
['notInclude', createSchemaModifierKeysNotInclude.bind(this)],
|
||||
])),
|
||||
},
|
||||
],
|
||||
[
|
||||
'flags',
|
||||
{
|
||||
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
|
||||
['are', createSchemaFlagsAre.bind(this)],
|
||||
['areNot', createSchemaFlagsAreNot.bind(this)],
|
||||
['include', createSchemaFlagsInclude.bind(this)],
|
||||
['notInclude', createSchemaFlagsNotInclude.bind(this)],
|
||||
])),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Creates a new JSON schema descriptor for the given set of condition groups.
|
||||
* @param {import('settings').ProfileConditionGroup[]} conditionGroups An array of condition groups.
|
||||
* For a profile match, all of the items must return successfully in at least one of the groups.
|
||||
* @returns {JsonSchema} A new `JsonSchema` object.
|
||||
*/
|
||||
export function createSchema(conditionGroups) {
|
||||
const anyOf = [];
|
||||
for (const {conditions} of conditionGroups) {
|
||||
const allOf = [];
|
||||
for (const {type, operator, value} of conditions) {
|
||||
const conditionDescriptor = descriptors.get(type);
|
||||
if (typeof conditionDescriptor === 'undefined') { continue; }
|
||||
|
||||
const createSchema2 = conditionDescriptor.operators.get(operator);
|
||||
if (typeof createSchema2 === 'undefined') { continue; }
|
||||
|
||||
const schema = createSchema2(value);
|
||||
allOf.push(schema);
|
||||
}
|
||||
switch (allOf.length) {
|
||||
case 0: break;
|
||||
case 1: anyOf.push(allOf[0]); break;
|
||||
default: anyOf.push({allOf}); break;
|
||||
}
|
||||
}
|
||||
let schema;
|
||||
switch (anyOf.length) {
|
||||
case 0: schema = {}; break;
|
||||
case 1: schema = anyOf[0]; break;
|
||||
default: schema = {anyOf}; break;
|
||||
}
|
||||
return new JsonSchema(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a normalized version of the context object to test,
|
||||
* assigning dependent fields as needed.
|
||||
* @param {import('settings').OptionsContext} context A context object which is used during schema validation.
|
||||
* @returns {import('profile-conditions-util').NormalizedOptionsContext} A normalized context object.
|
||||
*/
|
||||
export function normalizeContext(context) {
|
||||
const normalizedContext = /** @type {import('profile-conditions-util').NormalizedOptionsContext} */ (Object.assign({}, context));
|
||||
const {url} = normalizedContext;
|
||||
if (typeof url === 'string') {
|
||||
try {
|
||||
normalizedContext.domain = new URL(url).hostname;
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
const {flags} = normalizedContext;
|
||||
if (!Array.isArray(flags)) {
|
||||
normalizedContext.flags = [];
|
||||
}
|
||||
return normalizedContext;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function split(value) {
|
||||
return value.split(splitPattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {number}
|
||||
*/
|
||||
function stringToNumber(value) {
|
||||
const number = Number.parseFloat(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
// popupLevel schema creation functions
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaPopupLevelEqual(value) {
|
||||
const number = stringToNumber(value);
|
||||
return {
|
||||
required: ['depth'],
|
||||
properties: {
|
||||
depth: {const: number},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaPopupLevelNotEqual(value) {
|
||||
return {
|
||||
not: {
|
||||
anyOf: [createSchemaPopupLevelEqual(value)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaPopupLevelLessThan(value) {
|
||||
const number = stringToNumber(value);
|
||||
return {
|
||||
required: ['depth'],
|
||||
properties: {
|
||||
depth: {type: 'number', exclusiveMaximum: number},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaPopupLevelGreaterThan(value) {
|
||||
const number = stringToNumber(value);
|
||||
return {
|
||||
required: ['depth'],
|
||||
properties: {
|
||||
depth: {type: 'number', exclusiveMinimum: number},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaPopupLevelLessThanOrEqual(value) {
|
||||
const number = stringToNumber(value);
|
||||
return {
|
||||
required: ['depth'],
|
||||
properties: {
|
||||
depth: {type: 'number', maximum: number},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaPopupLevelGreaterThanOrEqual(value) {
|
||||
const number = stringToNumber(value);
|
||||
return {
|
||||
required: ['depth'],
|
||||
properties: {
|
||||
depth: {type: 'number', minimum: number},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// URL schema creation functions
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaUrlMatchDomain(value) {
|
||||
const oneOf = [];
|
||||
for (let domain of split(value)) {
|
||||
if (domain.length === 0) { continue; }
|
||||
domain = domain.toLowerCase();
|
||||
oneOf.push({const: domain});
|
||||
}
|
||||
return {
|
||||
required: ['domain'],
|
||||
properties: {
|
||||
domain: {oneOf},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaUrlMatchRegExp(value) {
|
||||
return {
|
||||
required: ['url'],
|
||||
properties: {
|
||||
url: {type: 'string', pattern: value, patternFlags: 'i'},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// modifierKeys schema creation functions
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaModifierKeysAre(value) {
|
||||
return createSchemaArrayCheck('modifierKeys', value, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaModifierKeysAreNot(value) {
|
||||
return {
|
||||
not: {
|
||||
anyOf: [createSchemaArrayCheck('modifierKeys', value, true, false)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaModifierKeysInclude(value) {
|
||||
return createSchemaArrayCheck('modifierKeys', value, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaModifierKeysNotInclude(value) {
|
||||
return createSchemaArrayCheck('modifierKeys', value, false, true);
|
||||
}
|
||||
|
||||
// modifierKeys schema creation functions
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaFlagsAre(value) {
|
||||
return createSchemaArrayCheck('flags', value, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaFlagsAreNot(value) {
|
||||
return {
|
||||
not: {
|
||||
anyOf: [createSchemaArrayCheck('flags', value, true, false)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaFlagsInclude(value) {
|
||||
return createSchemaArrayCheck('flags', value, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaFlagsNotInclude(value) {
|
||||
return createSchemaArrayCheck('flags', value, false, true);
|
||||
}
|
||||
|
||||
// Generic
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
* @param {boolean} exact
|
||||
* @param {boolean} none
|
||||
* @returns {import('ext/json-schema').Schema}
|
||||
*/
|
||||
function createSchemaArrayCheck(key, value, exact, none) {
|
||||
/** @type {import('ext/json-schema').Schema[]} */
|
||||
const containsList = [];
|
||||
for (const item of split(value)) {
|
||||
if (item.length === 0) { continue; }
|
||||
containsList.push({
|
||||
contains: {
|
||||
const: item,
|
||||
},
|
||||
});
|
||||
}
|
||||
const containsListCount = containsList.length;
|
||||
/** @type {import('ext/json-schema').Schema} */
|
||||
const schema = {
|
||||
type: 'array',
|
||||
};
|
||||
if (exact) {
|
||||
schema.maxItems = containsListCount;
|
||||
}
|
||||
if (none) {
|
||||
if (containsListCount > 0) {
|
||||
schema.not = {anyOf: containsList};
|
||||
}
|
||||
} else {
|
||||
schema.minItems = containsListCount;
|
||||
if (containsListCount > 0) {
|
||||
schema.allOf = containsList;
|
||||
}
|
||||
}
|
||||
return {
|
||||
required: [key],
|
||||
properties: {
|
||||
[key]: schema,
|
||||
},
|
||||
};
|
||||
}
|
||||
339
vendor/yomitan/js/background/request-builder.js
vendored
Normal file
339
vendor/yomitan/js/background/request-builder.js
vendored
Normal file
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This class is used to generate `fetch()` requests on the background page
|
||||
* with additional controls over anonymity and error handling.
|
||||
*/
|
||||
export class RequestBuilder {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {TextEncoder} */
|
||||
this._textEncoder = new TextEncoder();
|
||||
/** @type {Set<number>} */
|
||||
this._ruleIds = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the instance.
|
||||
*/
|
||||
async prepare() {
|
||||
try {
|
||||
await this._clearDynamicRules();
|
||||
await this._clearSessionRules();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an anonymized fetch request, which strips the `Cookie` header and adjust the `Origin` header.
|
||||
* @param {string} url The URL to fetch.
|
||||
* @param {RequestInit} init The initialization parameters passed to the `fetch` function.
|
||||
* @returns {Promise<Response>} The response of the `fetch` call.
|
||||
*/
|
||||
async fetchAnonymous(url, init) {
|
||||
const id = this._getNewRuleId();
|
||||
const originUrl = this._getOriginURL(url);
|
||||
url = encodeURI(decodeURIComponent(url));
|
||||
|
||||
this._ruleIds.add(id);
|
||||
try {
|
||||
/** @type {chrome.declarativeNetRequest.Rule[]} */
|
||||
const addRules = [{
|
||||
id,
|
||||
priority: 1,
|
||||
condition: {
|
||||
urlFilter: `|${this._escapeDnrUrl(url)}|`,
|
||||
resourceTypes: [
|
||||
/** @type {chrome.declarativeNetRequest.ResourceType} */ ('xmlhttprequest'),
|
||||
],
|
||||
},
|
||||
action: {
|
||||
type: /** @type {chrome.declarativeNetRequest.RuleActionType} */ ('modifyHeaders'),
|
||||
requestHeaders: [
|
||||
{
|
||||
operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'),
|
||||
header: 'Cookie',
|
||||
},
|
||||
{
|
||||
operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('set'),
|
||||
header: 'Origin',
|
||||
value: originUrl,
|
||||
},
|
||||
],
|
||||
responseHeaders: [
|
||||
{
|
||||
operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'),
|
||||
header: 'Set-Cookie',
|
||||
},
|
||||
],
|
||||
},
|
||||
}];
|
||||
|
||||
await this._updateSessionRules({addRules});
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} finally {
|
||||
await this._tryUpdateSessionRules({removeRuleIds: [id]});
|
||||
}
|
||||
} finally {
|
||||
this._ruleIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the array buffer body of a fetch response, with an optional `onProgress` callback.
|
||||
* @param {Response} response The response of a `fetch` call.
|
||||
* @param {?(done: boolean) => void} onProgress The progress callback.
|
||||
* @returns {Promise<Uint8Array>} The resulting binary data.
|
||||
*/
|
||||
static async readFetchResponseArrayBuffer(response, onProgress) {
|
||||
/** @type {ReadableStreamDefaultReader<Uint8Array>|undefined} */
|
||||
let reader;
|
||||
try {
|
||||
if (onProgress !== null) {
|
||||
const {body} = response;
|
||||
if (body !== null) {
|
||||
reader = body.getReader();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not supported
|
||||
}
|
||||
|
||||
if (typeof reader === 'undefined') {
|
||||
const result = await response.arrayBuffer();
|
||||
if (onProgress !== null) {
|
||||
onProgress(true);
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
const contentLengthString = response.headers.get('Content-Length');
|
||||
const contentLength = contentLengthString !== null ? Number.parseInt(contentLengthString, 10) : null;
|
||||
let target = contentLength !== null && Number.isFinite(contentLength) ? new Uint8Array(contentLength) : null;
|
||||
let targetPosition = 0;
|
||||
let totalLength = 0;
|
||||
const targets = [];
|
||||
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) { break; }
|
||||
if (onProgress !== null) {
|
||||
onProgress(false);
|
||||
}
|
||||
if (target === null) {
|
||||
targets.push({array: value, length: value.length});
|
||||
} else if (targetPosition + value.length > target.length) {
|
||||
targets.push({array: target, length: targetPosition});
|
||||
target = null;
|
||||
} else {
|
||||
target.set(value, targetPosition);
|
||||
targetPosition += value.length;
|
||||
}
|
||||
totalLength += value.length;
|
||||
}
|
||||
|
||||
if (target === null) {
|
||||
target = this._joinUint8Arrays(targets, totalLength);
|
||||
} else if (totalLength < target.length) {
|
||||
target = target.slice(0, totalLength);
|
||||
}
|
||||
|
||||
if (onProgress !== null) {
|
||||
onProgress(true);
|
||||
}
|
||||
|
||||
return /** @type {Uint8Array} */ (target);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
async _clearSessionRules() {
|
||||
const rules = await this._getSessionRules();
|
||||
|
||||
if (rules.length === 0) { return; }
|
||||
|
||||
const removeRuleIds = [];
|
||||
for (const {id} of rules) {
|
||||
removeRuleIds.push(id);
|
||||
}
|
||||
|
||||
await this._updateSessionRules({removeRuleIds});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<chrome.declarativeNetRequest.Rule[]>}
|
||||
*/
|
||||
_getSessionRules() {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.declarativeNetRequest.getSessionRules((result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.declarativeNetRequest.UpdateRuleOptions} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_updateSessionRules(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.declarativeNetRequest.updateSessionRules(options, () => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.declarativeNetRequest.UpdateRuleOptions} options
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _tryUpdateSessionRules(options) {
|
||||
try {
|
||||
await this._updateSessionRules(options);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _clearDynamicRules() {
|
||||
const rules = await this._getDynamicRules();
|
||||
|
||||
if (rules.length === 0) { return; }
|
||||
|
||||
const removeRuleIds = [];
|
||||
for (const {id} of rules) {
|
||||
removeRuleIds.push(id);
|
||||
}
|
||||
|
||||
await this._updateDynamicRules({removeRuleIds});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<chrome.declarativeNetRequest.Rule[]>}
|
||||
*/
|
||||
_getDynamicRules() {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.declarativeNetRequest.getDynamicRules((result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.declarativeNetRequest.UpdateRuleOptions} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_updateDynamicRules(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.declarativeNetRequest.updateDynamicRules(options, () => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_getNewRuleId() {
|
||||
let id = 1;
|
||||
while (this._ruleIds.has(id)) {
|
||||
const pre = id;
|
||||
++id;
|
||||
if (id === pre) { throw new Error('Could not generate an id'); }
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
_getOriginURL(url) {
|
||||
const url2 = new URL(url);
|
||||
return `${url2.protocol}//${url2.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
_escapeDnrUrl(url) {
|
||||
return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
_urlEncodeUtf8(text) {
|
||||
const array = this._textEncoder.encode(text);
|
||||
let result = '';
|
||||
for (const byte of array) {
|
||||
result += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{array: Uint8Array, length: number}[]} items
|
||||
* @param {number} totalLength
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static _joinUint8Arrays(items, totalLength) {
|
||||
if (items.length === 1) {
|
||||
const {array, length} = items[0];
|
||||
if (array.length === length) { return array; }
|
||||
}
|
||||
const result = new Uint8Array(totalLength);
|
||||
let position = 0;
|
||||
for (const {array, length} of items) {
|
||||
result.set(array, position);
|
||||
position += length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
165
vendor/yomitan/js/background/script-manager.js
vendored
Normal file
165
vendor/yomitan/js/background/script-manager.js
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Injects a stylesheet into a tab.
|
||||
* @param {'file'|'code'} type The type of content to inject; either 'file' or 'code'.
|
||||
* @param {string} content The content to inject.
|
||||
* - If type is `'file'`, this argument should be a path to a file.
|
||||
* - If type is `'code'`, this argument should be the CSS content.
|
||||
* @param {number} tabId The id of the tab to inject into.
|
||||
* @param {number|undefined} frameId The id of the frame to inject into.
|
||||
* @param {boolean} allFrames Whether or not the stylesheet should be injected into all frames.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function injectStylesheet(type, content, tabId, frameId, allFrames) {
|
||||
return new Promise((resolve, reject) => {
|
||||
/** @type {chrome.scripting.InjectionTarget} */
|
||||
const target = {
|
||||
tabId,
|
||||
allFrames,
|
||||
};
|
||||
/** @type {chrome.scripting.CSSInjection} */
|
||||
const details = (
|
||||
type === 'file' ?
|
||||
{origin: 'AUTHOR', files: [content], target} :
|
||||
{origin: 'USER', css: content, target}
|
||||
);
|
||||
if (!allFrames && typeof frameId === 'number') {
|
||||
details.target.frameIds = [frameId];
|
||||
}
|
||||
chrome.scripting.insertCSS(details, () => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not a content script is registered.
|
||||
* @param {string} id The identifier used with a call to `registerContentScript`.
|
||||
* @returns {Promise<boolean>} `true` if a script is registered, `false` otherwise.
|
||||
*/
|
||||
export async function isContentScriptRegistered(id) {
|
||||
const scripts = await getRegisteredContentScripts([id]);
|
||||
for (const script of scripts) {
|
||||
if (script.id === id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a dynamic content script.
|
||||
* Note: if the fallback handler is used and the 'webNavigation' permission isn't granted,
|
||||
* there is a possibility that the script can be injected more than once due to the events used.
|
||||
* Therefore, a reentrant check may need to be performed by the content script.
|
||||
* @param {string} id A unique identifier for the registration.
|
||||
* @param {import('script-manager').RegistrationDetails} details The script registration details.
|
||||
* @throws An error is thrown if the id is already in use.
|
||||
*/
|
||||
export async function registerContentScript(id, details) {
|
||||
if (await isContentScriptRegistered(id)) {
|
||||
throw new Error('Registration already exists');
|
||||
}
|
||||
|
||||
const details2 = createContentScriptRegistrationOptions(details, id);
|
||||
await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
|
||||
chrome.scripting.registerContentScripts([details2], () => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a previously registered content script.
|
||||
* @param {string} id The identifier passed to a previous call to `registerContentScript`.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function unregisterContentScript(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.scripting.unregisterContentScripts({ids: [id]}, () => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('script-manager').RegistrationDetails} details
|
||||
* @param {string} id
|
||||
* @returns {chrome.scripting.RegisteredContentScript}
|
||||
*/
|
||||
function createContentScriptRegistrationOptions(details, id) {
|
||||
const {css, js, allFrames, matches, runAt, world} = details;
|
||||
/** @type {chrome.scripting.RegisteredContentScript} */
|
||||
const options = {
|
||||
id: id,
|
||||
persistAcrossSessions: true,
|
||||
};
|
||||
if (Array.isArray(css)) {
|
||||
options.css = [...css];
|
||||
}
|
||||
if (Array.isArray(js)) {
|
||||
options.js = [...js];
|
||||
}
|
||||
if (typeof allFrames !== 'undefined') {
|
||||
options.allFrames = allFrames;
|
||||
}
|
||||
if (Array.isArray(matches)) {
|
||||
options.matches = [...matches];
|
||||
}
|
||||
if (typeof runAt !== 'undefined') {
|
||||
options.runAt = runAt;
|
||||
}
|
||||
if (typeof world !== 'undefined') {
|
||||
options.world = world;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} ids
|
||||
* @returns {Promise<chrome.scripting.RegisteredContentScript[]>}
|
||||
*/
|
||||
function getRegisteredContentScripts(ids) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.scripting.getRegisteredContentScripts({ids}, (result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
775
vendor/yomitan/js/comm/anki-connect.js
vendored
Normal file
775
vendor/yomitan/js/comm/anki-connect.js
vendored
Normal file
@@ -0,0 +1,775 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2016-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 {isObjectNotArray} from '../core/object-utilities.js';
|
||||
import {getRootDeckName} from '../data/anki-util.js';
|
||||
|
||||
/**
|
||||
* This class controls communication with Anki via the AnkiConnect plugin.
|
||||
*/
|
||||
export class AnkiConnect {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {boolean} */
|
||||
this._enabled = false;
|
||||
/** @type {?string} */
|
||||
this._server = null;
|
||||
/** @type {number} */
|
||||
this._localVersion = 2;
|
||||
/** @type {number} */
|
||||
this._remoteVersion = 0;
|
||||
/** @type {?Promise<number>} */
|
||||
this._versionCheckPromise = null;
|
||||
/** @type {?string} */
|
||||
this._apiKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the URL of the AnkiConnect server.
|
||||
* @type {?string}
|
||||
*/
|
||||
get server() {
|
||||
return this._server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the URL of the AnkiConnect server.
|
||||
* @param {string} value The new server URL to assign.
|
||||
*/
|
||||
set server(value) {
|
||||
this._server = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets whether or not server communication is enabled.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get enabled() {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether or not server communication is enabled.
|
||||
* @param {boolean} value The enabled state.
|
||||
*/
|
||||
set enabled(value) {
|
||||
this._enabled = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the API key used when connecting to AnkiConnect.
|
||||
* The value will be `null` if no API key is used.
|
||||
* @type {?string}
|
||||
*/
|
||||
get apiKey() {
|
||||
return this._apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the API key used when connecting to AnkiConnect.
|
||||
* @param {?string} value The API key to use, or `null` if no API key should be used.
|
||||
*/
|
||||
set apiKey(value) {
|
||||
this._apiKey = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a connection to AnkiConnect can be established.
|
||||
* @returns {Promise<boolean>} `true` if the connection was made, `false` otherwise.
|
||||
*/
|
||||
async isConnected() {
|
||||
try {
|
||||
await this._getVersion();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the AnkiConnect API version number.
|
||||
* @returns {Promise<?number>} The version number
|
||||
*/
|
||||
async getVersion() {
|
||||
if (!this._enabled) { return null; }
|
||||
await this._checkVersion();
|
||||
return await this._getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note} note
|
||||
* @returns {Promise<?import('anki').NoteId>}
|
||||
*/
|
||||
async addNote(note) {
|
||||
if (!this._enabled) { return null; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('addNote', {note});
|
||||
if (result !== null && typeof result !== 'number') {
|
||||
throw this._createUnexpectedResultError('number|null', result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note[]} notes
|
||||
* @returns {Promise<?((number | null)[] | null)>}
|
||||
*/
|
||||
async addNotes(notes) {
|
||||
if (!this._enabled) { return null; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('addNotes', {notes});
|
||||
if (result !== null && !Array.isArray(result)) {
|
||||
throw this._createUnexpectedResultError('(number | null)[] | null', result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note} noteWithId
|
||||
* @returns {Promise<null>}
|
||||
*/
|
||||
async updateNoteFields(noteWithId) {
|
||||
if (!this._enabled) { return null; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('updateNoteFields', {note: noteWithId});
|
||||
if (result !== null) {
|
||||
throw this._createUnexpectedResultError('null', result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note[]} notes
|
||||
* @returns {Promise<boolean[]>}
|
||||
*/
|
||||
async canAddNotes(notes) {
|
||||
if (!this._enabled) { return new Array(notes.length).fill(false); }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('canAddNotes', {notes});
|
||||
return this._normalizeArray(result, notes.length, 'boolean');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note[]} notes
|
||||
* @returns {Promise<import('anki').CanAddNotesDetail[]>}
|
||||
*/
|
||||
async canAddNotesWithErrorDetail(notes) {
|
||||
if (!this._enabled) { return notes.map(() => ({canAdd: false, error: null})); }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('canAddNotesWithErrorDetail', {notes});
|
||||
return this._normalizeCanAddNotesWithErrorDetailArray(result, notes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').NoteId[]} noteIds
|
||||
* @returns {Promise<(?import('anki').NoteInfo)[]>}
|
||||
*/
|
||||
async notesInfo(noteIds) {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('notesInfo', {notes: noteIds});
|
||||
return this._normalizeNoteInfoArray(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').CardId[]} cardIds
|
||||
* @returns {Promise<(?import('anki').CardInfo)[]>}
|
||||
*/
|
||||
async cardsInfo(cardIds) {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('cardsInfo', {cards: cardIds});
|
||||
return this._normalizeCardInfoArray(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async getDeckNames() {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('deckNames', {});
|
||||
return this._normalizeArray(result, -1, 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async getModelNames() {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('modelNames', {});
|
||||
return this._normalizeArray(result, -1, 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async getModelFieldNames(modelName) {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('modelFieldNames', {modelName});
|
||||
return this._normalizeArray(result, -1, 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @returns {Promise<import('anki').CardId[]>}
|
||||
*/
|
||||
async guiBrowse(query) {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('guiBrowse', {query});
|
||||
return this._normalizeArray(result, -1, 'number');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').NoteId} noteId
|
||||
* @returns {Promise<import('anki').CardId[]>}
|
||||
*/
|
||||
async guiBrowseNote(noteId) {
|
||||
return await this.guiBrowse(`nid:${noteId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').NoteId[]} noteIds
|
||||
* @returns {Promise<import('anki').CardId[]>}
|
||||
*/
|
||||
async guiBrowseNotes(noteIds) {
|
||||
return await this.guiBrowse(`nid:${noteIds.join(',')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the note editor GUI.
|
||||
* @param {import('anki').NoteId} noteId The ID of the note.
|
||||
* @returns {Promise<void>} Nothing is returned.
|
||||
*/
|
||||
async guiEditNote(noteId) {
|
||||
await this._invoke('guiEditNote', {note: noteId});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a file with the specified base64-encoded content inside Anki's media folder.
|
||||
* @param {string} fileName The name of the file.
|
||||
* @param {string} content The base64-encoded content of the file.
|
||||
* @returns {Promise<?string>} The actual file name used to store the file, which may be different; or `null` if the file was not stored.
|
||||
* @throws {Error} An error is thrown is this object is not enabled.
|
||||
*/
|
||||
async storeMediaFile(fileName, content) {
|
||||
if (!this._enabled) {
|
||||
throw new Error('AnkiConnect not enabled');
|
||||
}
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('storeMediaFile', {filename: fileName, data: content});
|
||||
if (result !== null && typeof result !== 'string') {
|
||||
throw this._createUnexpectedResultError('string|null', result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds notes matching a query.
|
||||
* @param {string} query Searches for notes matching a query.
|
||||
* @returns {Promise<import('anki').NoteId[]>} An array of note IDs.
|
||||
* @see https://docs.ankiweb.net/searching.html
|
||||
*/
|
||||
async findNotes(query) {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('findNotes', {query});
|
||||
return this._normalizeArray(result, -1, 'number');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note[]} notes
|
||||
* @returns {Promise<import('anki').NoteId[][]>}
|
||||
*/
|
||||
async findNoteIds(notes) {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
|
||||
const actions = [];
|
||||
const actionsTargetsList = [];
|
||||
/** @type {Map<string, import('anki').NoteId[][]>} */
|
||||
const actionsTargetsMap = new Map();
|
||||
/** @type {import('anki').NoteId[][]} */
|
||||
const allNoteIds = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const query = this._getNoteQuery(note);
|
||||
let actionsTargets = actionsTargetsMap.get(query);
|
||||
if (typeof actionsTargets === 'undefined') {
|
||||
actionsTargets = [];
|
||||
actionsTargetsList.push(actionsTargets);
|
||||
actionsTargetsMap.set(query, actionsTargets);
|
||||
actions.push({action: 'findNotes', params: {query}});
|
||||
}
|
||||
/** @type {import('anki').NoteId[]} */
|
||||
const noteIds = [];
|
||||
allNoteIds.push(noteIds);
|
||||
actionsTargets.push(noteIds);
|
||||
}
|
||||
|
||||
const result = await this._invokeMulti(actions);
|
||||
for (let i = 0, ii = Math.min(result.length, actionsTargetsList.length); i < ii; ++i) {
|
||||
const noteIds = /** @type {number[]} */ (this._normalizeArray(result[i], -1, 'number'));
|
||||
for (const actionsTargets of actionsTargetsList[i]) {
|
||||
for (const noteId of noteIds) {
|
||||
actionsTargets.push(noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allNoteIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').CardId[]} cardIds
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async suspendCards(cardIds) {
|
||||
if (!this._enabled) { return false; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('suspend', {cards: cardIds});
|
||||
return typeof result === 'boolean' && result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @returns {Promise<import('anki').CardId[]>}
|
||||
*/
|
||||
async findCards(query) {
|
||||
if (!this._enabled) { return []; }
|
||||
await this._checkVersion();
|
||||
const result = await this._invoke('findCards', {query});
|
||||
return this._normalizeArray(result, -1, 'number');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').NoteId} noteId
|
||||
* @returns {Promise<import('anki').CardId[]>}
|
||||
*/
|
||||
async findCardsForNote(noteId) {
|
||||
return await this.findCards(`nid:${noteId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets information about the AnkiConnect APIs available.
|
||||
* @param {string[]} scopes A list of scopes to get information about.
|
||||
* @param {?string[]} actions A list of actions to check for
|
||||
* @returns {Promise<import('anki').ApiReflectResult>} Information about the APIs.
|
||||
*/
|
||||
async apiReflect(scopes, actions = null) {
|
||||
const result = await this._invoke('apiReflect', {scopes, actions});
|
||||
if (!(typeof result === 'object' && result !== null)) {
|
||||
throw this._createUnexpectedResultError('object', result);
|
||||
}
|
||||
const {scopes: resultScopes, actions: resultActions} = /** @type {import('core').SerializableObject} */ (result);
|
||||
const resultScopes2 = /** @type {string[]} */ (this._normalizeArray(resultScopes, -1, 'string', ', field scopes'));
|
||||
const resultActions2 = /** @type {string[]} */ (this._normalizeArray(resultActions, -1, 'string', ', field scopes'));
|
||||
return {
|
||||
scopes: resultScopes2,
|
||||
actions: resultActions2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a specific API action exists.
|
||||
* @param {string} action The action to check for.
|
||||
* @returns {Promise<boolean>} Whether or not the action exists.
|
||||
*/
|
||||
async apiExists(action) {
|
||||
const {actions} = await this.apiReflect(['actions'], [action]);
|
||||
return actions.includes(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific error object corresponds to an unsupported action.
|
||||
* @param {Error} error An error object generated by an API call.
|
||||
* @returns {boolean} Whether or not the error indicates the action is not supported.
|
||||
*/
|
||||
isErrorUnsupportedAction(error) {
|
||||
if (error instanceof ExtensionError) {
|
||||
const {data} = error;
|
||||
if (typeof data === 'object' && data !== null && /** @type {import('core').SerializableObject} */ (data).apiError === 'unsupported action') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes Anki sync.
|
||||
* @returns {Promise<?unknown>}
|
||||
*/
|
||||
async makeAnkiSync() {
|
||||
if (!this._enabled) { return null; }
|
||||
const version = await this._checkVersion();
|
||||
const result = await this._invoke('sync', {version});
|
||||
return result === null;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _checkVersion() {
|
||||
if (this._remoteVersion < this._localVersion) {
|
||||
if (this._versionCheckPromise === null) {
|
||||
const promise = this._getVersion();
|
||||
promise
|
||||
.catch(() => {})
|
||||
.finally(() => { this._versionCheckPromise = null; });
|
||||
this._versionCheckPromise = promise;
|
||||
}
|
||||
this._remoteVersion = await this._versionCheckPromise;
|
||||
if (this._remoteVersion < this._localVersion) {
|
||||
throw new Error('Extension and plugin versions incompatible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} action
|
||||
* @param {import('core').SerializableObject} params
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
async _invoke(action, params) {
|
||||
/** @type {import('anki').MessageBody} */
|
||||
const body = {action, params, version: this._localVersion};
|
||||
if (this._apiKey !== null) { body.key = this._apiKey; }
|
||||
let response;
|
||||
try {
|
||||
if (this._server === null) { throw new Error('Server URL is null'); }
|
||||
response = await fetch(this._server, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
cache: 'default',
|
||||
credentials: 'omit',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
redirect: 'follow',
|
||||
referrerPolicy: 'no-referrer',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
const error = new ExtensionError('Anki connection failure');
|
||||
error.data = {action, params, originalError: e};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new ExtensionError(`Anki connection error: ${response.status}`);
|
||||
error.data = {action, params, status: response.status};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let responseText = null;
|
||||
/** @type {unknown} */
|
||||
let result;
|
||||
try {
|
||||
responseText = await response.text();
|
||||
result = parseJson(responseText);
|
||||
} catch (e) {
|
||||
const error = new ExtensionError('Invalid Anki response');
|
||||
error.data = {action, params, status: response.status, responseText, originalError: e};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
|
||||
const apiError = /** @type {import('core').SerializableObject} */ (result).error;
|
||||
if (typeof apiError !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
const error = new ExtensionError(`Anki error: ${apiError}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
error.data = {action, params, status: response.status, apiError: typeof apiError === 'string' ? apiError : `${apiError}`};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{action: string, params: import('core').SerializableObject}[]} actions
|
||||
* @returns {Promise<unknown[]>}
|
||||
*/
|
||||
async _invokeMulti(actions) {
|
||||
const result = await this._invoke('multi', {actions});
|
||||
if (!Array.isArray(result)) {
|
||||
throw this._createUnexpectedResultError('array', result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
_escapeQuery(text) {
|
||||
return text.replace(/"/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').NoteFields} fields
|
||||
* @returns {string}
|
||||
*/
|
||||
_fieldsToQuery(fields) {
|
||||
const fieldNames = Object.keys(fields);
|
||||
if (fieldNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const key = fieldNames[0];
|
||||
return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note} note
|
||||
* @returns {?('collection'|'deck'|'deck-root')}
|
||||
*/
|
||||
_getDuplicateScopeFromNote(note) {
|
||||
const {options} = note;
|
||||
if (typeof options === 'object' && options !== null) {
|
||||
const {duplicateScope} = options;
|
||||
if (typeof duplicateScope !== 'undefined') {
|
||||
return duplicateScope;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki').Note} note
|
||||
* @returns {string}
|
||||
*/
|
||||
_getNoteQuery(note) {
|
||||
let query = '';
|
||||
switch (this._getDuplicateScopeFromNote(note)) {
|
||||
case 'deck':
|
||||
query = `"deck:${this._escapeQuery(note.deckName)}" `;
|
||||
break;
|
||||
case 'deck-root':
|
||||
query = `"deck:${this._escapeQuery(getRootDeckName(note.deckName))}" `;
|
||||
break;
|
||||
}
|
||||
query += this._fieldsToQuery(note.fields);
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async _getVersion() {
|
||||
const version = await this._invoke('version', {});
|
||||
return typeof version === 'number' ? version : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {unknown} data
|
||||
* @returns {ExtensionError}
|
||||
*/
|
||||
_createError(message, data) {
|
||||
const error = new ExtensionError(message);
|
||||
error.data = data;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} expectedType
|
||||
* @param {unknown} result
|
||||
* @param {string} [context]
|
||||
* @returns {ExtensionError}
|
||||
*/
|
||||
_createUnexpectedResultError(expectedType, result, context) {
|
||||
return this._createError(`Unexpected type${typeof context === 'string' ? context : ''}: expected ${expectedType}, received ${this._getTypeName(result)}`, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_getTypeName(value) {
|
||||
if (value === null) { return 'null'; }
|
||||
return Array.isArray(value) ? 'array' : typeof value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {unknown} result
|
||||
* @param {number} expectedCount
|
||||
* @param {'boolean'|'string'|'number'} type
|
||||
* @param {string} [context]
|
||||
* @returns {T[]}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_normalizeArray(result, expectedCount, type, context) {
|
||||
if (!Array.isArray(result)) {
|
||||
throw this._createUnexpectedResultError(`${type}[]`, result, context);
|
||||
}
|
||||
if (expectedCount < 0) {
|
||||
expectedCount = result.length;
|
||||
} else if (expectedCount !== result.length) {
|
||||
throw this._createError(`Unexpected result array size${context}: expected ${expectedCount}, received ${result.length}`, result);
|
||||
}
|
||||
for (let i = 0; i < expectedCount; ++i) {
|
||||
const item = /** @type {unknown} */ (result[i]);
|
||||
if (typeof item !== type) {
|
||||
throw this._createError(`Unexpected result type at index ${i}${context}: expected ${type}, received ${this._getTypeName(item)}`, result);
|
||||
}
|
||||
}
|
||||
return /** @type {T[]} */ (result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} result
|
||||
* @returns {(?import('anki').NoteInfo)[]}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_normalizeNoteInfoArray(result) {
|
||||
if (!Array.isArray(result)) {
|
||||
throw this._createUnexpectedResultError('array', result, '');
|
||||
}
|
||||
/** @type {(?import('anki').NoteInfo)[]} */
|
||||
const result2 = [];
|
||||
for (let i = 0, ii = result.length; i < ii; ++i) {
|
||||
const item = /** @type {unknown} */ (result[i]);
|
||||
if (item === null || typeof item !== 'object') {
|
||||
throw this._createError(`Unexpected result type at index ${i}: expected Notes.NoteInfo, received ${this._getTypeName(item)}`, result);
|
||||
}
|
||||
const {noteId} = /** @type {{[key: string]: unknown}} */ (item);
|
||||
if (typeof noteId !== 'number') {
|
||||
result2.push(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const {tags, fields, modelName, cards} = /** @type {{[key: string]: unknown}} */ (item);
|
||||
if (typeof modelName !== 'string') {
|
||||
throw this._createError(`Unexpected result type at index ${i}, field modelName: expected string, received ${this._getTypeName(modelName)}`, result);
|
||||
}
|
||||
if (!isObjectNotArray(fields)) {
|
||||
throw this._createError(`Unexpected result type at index ${i}, field fields: expected object, received ${this._getTypeName(fields)}`, result);
|
||||
}
|
||||
const tags2 = /** @type {string[]} */ (this._normalizeArray(tags, -1, 'string', ', field tags'));
|
||||
const cards2 = /** @type {number[]} */ (this._normalizeArray(cards, -1, 'number', ', field cards'));
|
||||
/** @type {{[key: string]: import('anki').NoteFieldInfo}} */
|
||||
const fields2 = {};
|
||||
for (const [key, fieldInfo] of Object.entries(fields)) {
|
||||
if (!isObjectNotArray(fieldInfo)) { continue; }
|
||||
const {value, order} = fieldInfo;
|
||||
if (typeof value !== 'string' || typeof order !== 'number') { continue; }
|
||||
fields2[key] = {value, order};
|
||||
}
|
||||
/** @type {import('anki').NoteInfo} */
|
||||
const item2 = {
|
||||
noteId,
|
||||
tags: tags2,
|
||||
fields: fields2,
|
||||
modelName,
|
||||
cards: cards2,
|
||||
cardsInfo: [],
|
||||
};
|
||||
result2.push(item2);
|
||||
}
|
||||
return result2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms raw AnkiConnect data into the CardInfo type.
|
||||
* @param {unknown} result
|
||||
* @returns {(?import('anki').CardInfo)[]}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_normalizeCardInfoArray(result) {
|
||||
if (!Array.isArray(result)) {
|
||||
throw this._createUnexpectedResultError('array', result, '');
|
||||
}
|
||||
/** @type {(?import('anki').CardInfo)[]} */
|
||||
const result2 = [];
|
||||
for (let i = 0, ii = result.length; i < ii; ++i) {
|
||||
const item = /** @type {unknown} */ (result[i]);
|
||||
if (item === null || typeof item !== 'object') {
|
||||
throw this._createError(`Unexpected result type at index ${i}: expected Cards.CardInfo, received ${this._getTypeName(item)}`, result);
|
||||
}
|
||||
const {cardId} = /** @type {{[key: string]: unknown}} */ (item);
|
||||
if (typeof cardId !== 'number') {
|
||||
result2.push(null);
|
||||
continue;
|
||||
}
|
||||
const {note, flags, queue} = /** @type {{[key: string]: unknown}} */ (item);
|
||||
if (typeof note !== 'number') {
|
||||
result2.push(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @type {import('anki').CardInfo} */
|
||||
const item2 = {
|
||||
noteId: note,
|
||||
cardId,
|
||||
flags: typeof flags === 'number' ? flags : 0,
|
||||
cardState: typeof queue === 'number' ? queue : 0,
|
||||
};
|
||||
result2.push(item2);
|
||||
}
|
||||
return result2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} result
|
||||
* @param {number} expectedCount
|
||||
* @returns {import('anki').CanAddNotesDetail[]}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_normalizeCanAddNotesWithErrorDetailArray(result, expectedCount) {
|
||||
if (!Array.isArray(result)) {
|
||||
throw this._createUnexpectedResultError('array', result, '');
|
||||
}
|
||||
if (expectedCount !== result.length) {
|
||||
throw this._createError(`Unexpected result array size: expected ${expectedCount}, received ${result.length}`, result);
|
||||
}
|
||||
/** @type {import('anki').CanAddNotesDetail[]} */
|
||||
const result2 = [];
|
||||
for (let i = 0; i < expectedCount; ++i) {
|
||||
const item = /** @type {unknown} */ (result[i]);
|
||||
if (item === null || typeof item !== 'object') {
|
||||
throw this._createError(`Unexpected result type at index ${i}: expected object, received ${this._getTypeName(item)}`, result);
|
||||
}
|
||||
|
||||
const {canAdd, error} = /** @type {{[key: string]: unknown}} */ (item);
|
||||
if (typeof canAdd !== 'boolean') {
|
||||
throw this._createError(`Unexpected result type at index ${i}, field canAdd: expected boolean, received ${this._getTypeName(canAdd)}`, result);
|
||||
}
|
||||
|
||||
const item2 = {
|
||||
canAdd: canAdd,
|
||||
error: typeof error === 'string' ? error : null,
|
||||
};
|
||||
|
||||
result2.push(item2);
|
||||
}
|
||||
return result2;
|
||||
}
|
||||
}
|
||||
498
vendor/yomitan/js/comm/api.js
vendored
Normal file
498
vendor/yomitan/js/comm/api.js
vendored
Normal file
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2016-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 {log} from '../core/log.js';
|
||||
|
||||
export class API {
|
||||
/**
|
||||
* @param {import('../extension/web-extension.js').WebExtension} webExtension
|
||||
* @param {Worker?} mediaDrawingWorker
|
||||
* @param {MessagePort?} backendPort
|
||||
*/
|
||||
constructor(webExtension, mediaDrawingWorker = null, backendPort = null) {
|
||||
/** @type {import('../extension/web-extension.js').WebExtension} */
|
||||
this._webExtension = webExtension;
|
||||
|
||||
/** @type {Worker?} */
|
||||
this._mediaDrawingWorker = mediaDrawingWorker;
|
||||
|
||||
/** @type {MessagePort?} */
|
||||
this._backendPort = backendPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'optionsGet', 'optionsContext'>} optionsContext
|
||||
* @returns {Promise<import('api').ApiReturn<'optionsGet'>>}
|
||||
*/
|
||||
optionsGet(optionsContext) {
|
||||
return this._invoke('optionsGet', {optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'optionsGetFull'>>}
|
||||
*/
|
||||
optionsGetFull() {
|
||||
return this._invoke('optionsGetFull', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'termsFind', 'text'>} text
|
||||
* @param {import('api').ApiParam<'termsFind', 'details'>} details
|
||||
* @param {import('api').ApiParam<'termsFind', 'optionsContext'>} optionsContext
|
||||
* @returns {Promise<import('api').ApiReturn<'termsFind'>>}
|
||||
*/
|
||||
termsFind(text, details, optionsContext) {
|
||||
return this._invoke('termsFind', {text, details, optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'parseText', 'text'>} text
|
||||
* @param {import('api').ApiParam<'parseText', 'optionsContext'>} optionsContext
|
||||
* @param {import('api').ApiParam<'parseText', 'scanLength'>} scanLength
|
||||
* @param {import('api').ApiParam<'parseText', 'useInternalParser'>} useInternalParser
|
||||
* @param {import('api').ApiParam<'parseText', 'useMecabParser'>} useMecabParser
|
||||
* @returns {Promise<import('api').ApiReturn<'parseText'>>}
|
||||
*/
|
||||
parseText(text, optionsContext, scanLength, useInternalParser, useMecabParser) {
|
||||
return this._invoke('parseText', {text, optionsContext, scanLength, useInternalParser, useMecabParser});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'kanjiFind', 'text'>} text
|
||||
* @param {import('api').ApiParam<'kanjiFind', 'optionsContext'>} optionsContext
|
||||
* @returns {Promise<import('api').ApiReturn<'kanjiFind'>>}
|
||||
*/
|
||||
kanjiFind(text, optionsContext) {
|
||||
return this._invoke('kanjiFind', {text, optionsContext});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'isAnkiConnected'>>}
|
||||
*/
|
||||
isAnkiConnected() {
|
||||
return this._invoke('isAnkiConnected', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'getAnkiConnectVersion'>>}
|
||||
*/
|
||||
getAnkiConnectVersion() {
|
||||
return this._invoke('getAnkiConnectVersion', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'addAnkiNote', 'note'>} note
|
||||
* @returns {Promise<import('api').ApiReturn<'addAnkiNote'>>}
|
||||
*/
|
||||
addAnkiNote(note) {
|
||||
return this._invoke('addAnkiNote', {note});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'updateAnkiNote', 'noteWithId'>} noteWithId
|
||||
* @returns {Promise<import('api').ApiReturn<'updateAnkiNote'>>}
|
||||
*/
|
||||
updateAnkiNote(noteWithId) {
|
||||
return this._invoke('updateAnkiNote', {noteWithId});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'getAnkiNoteInfo', 'notes'>} notes
|
||||
* @param {import('api').ApiParam<'getAnkiNoteInfo', 'fetchAdditionalInfo'>} fetchAdditionalInfo
|
||||
* @returns {Promise<import('api').ApiReturn<'getAnkiNoteInfo'>>}
|
||||
*/
|
||||
getAnkiNoteInfo(notes, fetchAdditionalInfo) {
|
||||
return this._invoke('getAnkiNoteInfo', {notes, fetchAdditionalInfo});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'timestamp'>} timestamp
|
||||
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'definitionDetails'>} definitionDetails
|
||||
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'audioDetails'>} audioDetails
|
||||
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'screenshotDetails'>} screenshotDetails
|
||||
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'clipboardDetails'>} clipboardDetails
|
||||
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'dictionaryMediaDetails'>} dictionaryMediaDetails
|
||||
* @returns {Promise<import('api').ApiReturn<'injectAnkiNoteMedia'>>}
|
||||
*/
|
||||
injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) {
|
||||
return this._invoke('injectAnkiNoteMedia', {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'viewNotes', 'noteIds'>} noteIds
|
||||
* @param {import('api').ApiParam<'viewNotes', 'mode'>} mode
|
||||
* @param {import('api').ApiParam<'viewNotes', 'allowFallback'>} allowFallback
|
||||
* @returns {Promise<import('api').ApiReturn<'viewNotes'>>}
|
||||
*/
|
||||
viewNotes(noteIds, mode, allowFallback) {
|
||||
return this._invoke('viewNotes', {noteIds, mode, allowFallback});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'suspendAnkiCardsForNote', 'noteId'>} noteId
|
||||
* @returns {Promise<import('api').ApiReturn<'suspendAnkiCardsForNote'>>}
|
||||
*/
|
||||
suspendAnkiCardsForNote(noteId) {
|
||||
return this._invoke('suspendAnkiCardsForNote', {noteId});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'getTermAudioInfoList', 'source'>} source
|
||||
* @param {import('api').ApiParam<'getTermAudioInfoList', 'term'>} term
|
||||
* @param {import('api').ApiParam<'getTermAudioInfoList', 'reading'>} reading
|
||||
* @param {import('api').ApiParam<'getTermAudioInfoList', 'languageSummary'>} languageSummary
|
||||
* @returns {Promise<import('api').ApiReturn<'getTermAudioInfoList'>>}
|
||||
*/
|
||||
getTermAudioInfoList(source, term, reading, languageSummary) {
|
||||
return this._invoke('getTermAudioInfoList', {source, term, reading, languageSummary});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'commandExec', 'command'>} command
|
||||
* @param {import('api').ApiParam<'commandExec', 'params'>} [params]
|
||||
* @returns {Promise<import('api').ApiReturn<'commandExec'>>}
|
||||
*/
|
||||
commandExec(command, params) {
|
||||
return this._invoke('commandExec', {command, params});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'sendMessageToFrame', 'frameId'>} frameId
|
||||
* @param {import('api').ApiParam<'sendMessageToFrame', 'message'>} message
|
||||
* @returns {Promise<import('api').ApiReturn<'sendMessageToFrame'>>}
|
||||
*/
|
||||
sendMessageToFrame(frameId, message) {
|
||||
return this._invoke('sendMessageToFrame', {frameId, message});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'broadcastTab', 'message'>} message
|
||||
* @returns {Promise<import('api').ApiReturn<'broadcastTab'>>}
|
||||
*/
|
||||
broadcastTab(message) {
|
||||
return this._invoke('broadcastTab', {message});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'frameInformationGet'>>}
|
||||
*/
|
||||
frameInformationGet() {
|
||||
return this._invoke('frameInformationGet', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'injectStylesheet', 'type'>} type
|
||||
* @param {import('api').ApiParam<'injectStylesheet', 'value'>} value
|
||||
* @returns {Promise<import('api').ApiReturn<'injectStylesheet'>>}
|
||||
*/
|
||||
injectStylesheet(type, value) {
|
||||
return this._invoke('injectStylesheet', {type, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'getStylesheetContent', 'url'>} url
|
||||
* @returns {Promise<import('api').ApiReturn<'getStylesheetContent'>>}
|
||||
*/
|
||||
getStylesheetContent(url) {
|
||||
return this._invoke('getStylesheetContent', {url});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'getEnvironmentInfo'>>}
|
||||
*/
|
||||
getEnvironmentInfo() {
|
||||
return this._invoke('getEnvironmentInfo', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'clipboardGet'>>}
|
||||
*/
|
||||
clipboardGet() {
|
||||
return this._invoke('clipboardGet', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'getZoom'>>}
|
||||
*/
|
||||
getZoom() {
|
||||
return this._invoke('getZoom', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'getDefaultAnkiFieldTemplates'>>}
|
||||
*/
|
||||
getDefaultAnkiFieldTemplates() {
|
||||
return this._invoke('getDefaultAnkiFieldTemplates', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'getDictionaryInfo'>>}
|
||||
*/
|
||||
getDictionaryInfo() {
|
||||
return this._invoke('getDictionaryInfo', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'purgeDatabase'>>}
|
||||
*/
|
||||
purgeDatabase() {
|
||||
return this._invoke('purgeDatabase', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'getMedia', 'targets'>} targets
|
||||
* @returns {Promise<import('api').ApiReturn<'getMedia'>>}
|
||||
*/
|
||||
getMedia(targets) {
|
||||
return this._invoke('getMedia', {targets});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').PmApiParam<'drawMedia', 'requests'>} requests
|
||||
* @param {Transferable[]} transferables
|
||||
*/
|
||||
drawMedia(requests, transferables) {
|
||||
this._mediaDrawingWorker?.postMessage({action: 'drawMedia', params: {requests}}, transferables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'logGenericErrorBackend', 'error'>} error
|
||||
* @param {import('api').ApiParam<'logGenericErrorBackend', 'level'>} level
|
||||
* @param {import('api').ApiParam<'logGenericErrorBackend', 'context'>} context
|
||||
* @returns {Promise<import('api').ApiReturn<'logGenericErrorBackend'>>}
|
||||
*/
|
||||
logGenericErrorBackend(error, level, context) {
|
||||
return this._invoke('logGenericErrorBackend', {error, level, context});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'logIndicatorClear'>>}
|
||||
*/
|
||||
logIndicatorClear() {
|
||||
return this._invoke('logIndicatorClear', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'modifySettings', 'targets'>} targets
|
||||
* @param {import('api').ApiParam<'modifySettings', 'source'>} source
|
||||
* @returns {Promise<import('api').ApiReturn<'modifySettings'>>}
|
||||
*/
|
||||
modifySettings(targets, source) {
|
||||
return this._invoke('modifySettings', {targets, source});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'getSettings', 'targets'>} targets
|
||||
* @returns {Promise<import('api').ApiReturn<'getSettings'>>}
|
||||
*/
|
||||
getSettings(targets) {
|
||||
return this._invoke('getSettings', {targets});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'setAllSettings', 'value'>} value
|
||||
* @param {import('api').ApiParam<'setAllSettings', 'source'>} source
|
||||
* @returns {Promise<import('api').ApiReturn<'setAllSettings'>>}
|
||||
*/
|
||||
setAllSettings(value, source) {
|
||||
return this._invoke('setAllSettings', {value, source});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParams<'getOrCreateSearchPopup'>} details
|
||||
* @returns {Promise<import('api').ApiReturn<'getOrCreateSearchPopup'>>}
|
||||
*/
|
||||
getOrCreateSearchPopup(details) {
|
||||
return this._invoke('getOrCreateSearchPopup', details);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'isTabSearchPopup', 'tabId'>} tabId
|
||||
* @returns {Promise<import('api').ApiReturn<'isTabSearchPopup'>>}
|
||||
*/
|
||||
isTabSearchPopup(tabId) {
|
||||
return this._invoke('isTabSearchPopup', {tabId});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'triggerDatabaseUpdated', 'type'>} type
|
||||
* @param {import('api').ApiParam<'triggerDatabaseUpdated', 'cause'>} cause
|
||||
* @returns {Promise<import('api').ApiReturn<'triggerDatabaseUpdated'>>}
|
||||
*/
|
||||
triggerDatabaseUpdated(type, cause) {
|
||||
return this._invoke('triggerDatabaseUpdated', {type, cause});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'testMecab'>>}
|
||||
*/
|
||||
testMecab() {
|
||||
return this._invoke('testMecab', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Promise<import('api').ApiReturn<'testYomitanApi'>>}
|
||||
*/
|
||||
testYomitanApi(url) {
|
||||
return this._invoke('testYomitanApi', {url});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'isTextLookupWorthy', 'text'>} text
|
||||
* @param {import('api').ApiParam<'isTextLookupWorthy', 'language'>} language
|
||||
* @returns {Promise<import('api').ApiReturn<'isTextLookupWorthy'>>}
|
||||
*/
|
||||
isTextLookupWorthy(text, language) {
|
||||
return this._invoke('isTextLookupWorthy', {text, language});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'getTermFrequencies', 'termReadingList'>} termReadingList
|
||||
* @param {import('api').ApiParam<'getTermFrequencies', 'dictionaries'>} dictionaries
|
||||
* @returns {Promise<import('api').ApiReturn<'getTermFrequencies'>>}
|
||||
*/
|
||||
getTermFrequencies(termReadingList, dictionaries) {
|
||||
return this._invoke('getTermFrequencies', {termReadingList, dictionaries});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'findAnkiNotes', 'query'>} query
|
||||
* @returns {Promise<import('api').ApiReturn<'findAnkiNotes'>>}
|
||||
*/
|
||||
findAnkiNotes(query) {
|
||||
return this._invoke('findAnkiNotes', {query});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ApiParam<'openCrossFramePort', 'targetTabId'>} targetTabId
|
||||
* @param {import('api').ApiParam<'openCrossFramePort', 'targetFrameId'>} targetFrameId
|
||||
* @returns {Promise<import('api').ApiReturn<'openCrossFramePort'>>}
|
||||
*/
|
||||
openCrossFramePort(targetTabId, targetFrameId) {
|
||||
return this._invoke('openCrossFramePort', {targetTabId, targetFrameId});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to keep the background page alive on Firefox MV3, as it does not support offscreen.
|
||||
* The reason that backend persistency is required on FF is actually different from the reason it's required on Chromium --
|
||||
* on Chromium, persistency (which we achieve via the offscreen page, not via this heartbeat) is required because the load time
|
||||
* for the IndexedDB is incredibly long, which makes the first lookup after the extension sleeps take one minute+, which is
|
||||
* not acceptable. However, on Firefox, the database is backed by sqlite and starts very fast. Instead, the problem is that the
|
||||
* media-drawing-worker on the frontend holds a MessagePort to the database-worker on the backend, which closes when the extension
|
||||
* sleeps, because the database-worker is killed and currently there is no way to detect a closed port due to
|
||||
* https://github.com/whatwg/html/issues/1766 / https://github.com/whatwg/html/issues/10201
|
||||
*
|
||||
* So this is our only choice. We can remove this once there is a way to gracefully detect the closed MessagePort and rebuild it.
|
||||
* @returns {Promise<import('api').ApiReturn<'heartbeat'>>}
|
||||
*/
|
||||
heartbeat() {
|
||||
return this._invoke('heartbeat', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transferable[]} transferables
|
||||
*/
|
||||
registerOffscreenPort(transferables) {
|
||||
this._pmInvoke('registerOffscreenPort', void 0, transferables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessagePort} port
|
||||
*/
|
||||
connectToDatabaseWorker(port) {
|
||||
this._pmInvoke('connectToDatabaseWorker', void 0, [port]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'getLanguageSummaries'>>}
|
||||
*/
|
||||
getLanguageSummaries() {
|
||||
return this._invoke('getLanguageSummaries', void 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('api').ApiReturn<'forceSync'>>}
|
||||
*/
|
||||
forceSync() {
|
||||
return this._invoke('forceSync', void 0);
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
/**
|
||||
* @template {import('api').ApiNames} TAction
|
||||
* @template {import('api').ApiParams<TAction>} TParams
|
||||
* @param {TAction} action
|
||||
* @param {TParams} params
|
||||
* @returns {Promise<import('api').ApiReturn<TAction>>}
|
||||
*/
|
||||
_invoke(action, params) {
|
||||
/** @type {import('api').ApiMessage<TAction>} */
|
||||
const data = {action, params};
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this._webExtension.sendMessage(data, (response) => {
|
||||
this._webExtension.getLastError();
|
||||
if (response !== null && typeof response === 'object') {
|
||||
const {error} = /** @type {import('core').UnknownObject} */ (response);
|
||||
if (typeof error !== 'undefined') {
|
||||
reject(ExtensionError.deserialize(/** @type {import('core').SerializedError} */(error)));
|
||||
} else {
|
||||
const {result} = /** @type {import('core').UnknownObject} */ (response);
|
||||
resolve(/** @type {import('api').ApiReturn<TAction>} */(result));
|
||||
}
|
||||
} else {
|
||||
const message = response === null ? 'Unexpected null response. You may need to refresh the page.' : `Unexpected response of type ${typeof response}. You may need to refresh the page.`;
|
||||
reject(new Error(`${message} (${JSON.stringify(data)})`));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('api').PmApiNames} TAction
|
||||
* @template {import('api').PmApiParams<TAction>} TParams
|
||||
* @param {TAction} action
|
||||
* @param {TParams} params
|
||||
* @param {Transferable[]} transferables
|
||||
*/
|
||||
_pmInvoke(action, params, transferables) {
|
||||
// on firefox, there is no service worker, so we instead use a MessageChannel which is established
|
||||
// via a handshake via a SharedWorker
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
if (this._backendPort === null) {
|
||||
log.error('no backend port available');
|
||||
return;
|
||||
}
|
||||
this._backendPort.postMessage({action, params}, transferables);
|
||||
} else {
|
||||
void navigator.serviceWorker.ready.then((serviceWorkerRegistration) => {
|
||||
if (serviceWorkerRegistration.active !== null) {
|
||||
serviceWorkerRegistration.active.postMessage({action, params}, transferables);
|
||||
} else {
|
||||
log.error(`[${self.constructor.name}] no active service worker`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
105
vendor/yomitan/js/comm/clipboard-monitor.js
vendored
Normal file
105
vendor/yomitan/js/comm/clipboard-monitor.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('clipboard-monitor').Events>
|
||||
*/
|
||||
export class ClipboardMonitor extends EventDispatcher {
|
||||
/**
|
||||
* @param {import('clipboard-monitor').ClipboardReaderLike} clipboardReader
|
||||
*/
|
||||
constructor(clipboardReader) {
|
||||
super();
|
||||
/** @type {import('clipboard-monitor').ClipboardReaderLike} */
|
||||
this._clipboardReader = clipboardReader;
|
||||
/** @type {?import('core').Timeout} */
|
||||
this._timerId = null;
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._timerToken = null;
|
||||
/** @type {number} */
|
||||
this._interval = 250;
|
||||
/** @type {?string} */
|
||||
this._previousText = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
start() {
|
||||
this.stop();
|
||||
|
||||
let canChange = false;
|
||||
/**
|
||||
* This token is used as a unique identifier to ensure that a new clipboard monitor
|
||||
* hasn't been started during the await call. The check below the await call
|
||||
* will exit early if the reference has changed.
|
||||
* @type {?import('core').TokenObject}
|
||||
*/
|
||||
const token = {};
|
||||
const intervalCallback = async () => {
|
||||
this._timerId = null;
|
||||
|
||||
let text = null;
|
||||
try {
|
||||
text = await this._clipboardReader.getText(false);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
if (this._timerToken !== token) { return; }
|
||||
|
||||
if (
|
||||
typeof text === 'string' &&
|
||||
(text = text.trim()).length > 0 &&
|
||||
text !== this._previousText
|
||||
) {
|
||||
this._previousText = text;
|
||||
if (canChange) {
|
||||
this.trigger('change', {text});
|
||||
}
|
||||
}
|
||||
|
||||
canChange = true;
|
||||
this._timerId = setTimeout(intervalCallback, this._interval);
|
||||
};
|
||||
|
||||
this._timerToken = token;
|
||||
|
||||
void intervalCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
stop() {
|
||||
this._timerToken = null;
|
||||
this._previousText = null;
|
||||
if (this._timerId !== null) {
|
||||
clearTimeout(this._timerId);
|
||||
this._timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?string} text
|
||||
*/
|
||||
setPreviousText(text) {
|
||||
this._previousText = text;
|
||||
}
|
||||
}
|
||||
225
vendor/yomitan/js/comm/clipboard-reader.js
vendored
Normal file
225
vendor/yomitan/js/comm/clipboard-reader.js
vendored
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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 {getFileExtensionFromImageMediaType} from '../media/media-util.js';
|
||||
|
||||
/**
|
||||
* Class which can read text and images from the clipboard.
|
||||
*/
|
||||
export class ClipboardReader {
|
||||
/**
|
||||
* @param {?Document} document
|
||||
* @param {?string} pasteTargetSelector
|
||||
* @param {?string} richContentPasteTargetSelector
|
||||
*/
|
||||
constructor(document, pasteTargetSelector, richContentPasteTargetSelector) {
|
||||
/** @type {?Document} */
|
||||
this._document = document;
|
||||
/** @type {?import('environment').Browser} */
|
||||
this._browser = null;
|
||||
/** @type {?HTMLTextAreaElement} */
|
||||
this._pasteTarget = null;
|
||||
/** @type {?string} */
|
||||
this._pasteTargetSelector = pasteTargetSelector;
|
||||
/** @type {?HTMLElement} */
|
||||
this._richContentPasteTarget = null;
|
||||
/** @type {?string} */
|
||||
this._richContentPasteTargetSelector = richContentPasteTargetSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the browser being used.
|
||||
* @type {?import('environment').Browser}
|
||||
*/
|
||||
get browser() {
|
||||
return this._browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the browser being used.
|
||||
*/
|
||||
set browser(value) {
|
||||
this._browser = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the text in the clipboard.
|
||||
* @param {boolean} useRichText Whether or not to use rich text for pasting, when possible.
|
||||
* @returns {Promise<string>} A string containing the clipboard text.
|
||||
* @throws {Error} Error if not supported.
|
||||
*/
|
||||
async getText(useRichText) {
|
||||
/*
|
||||
Notes:
|
||||
document.execCommand('paste') sometimes doesn't work on Firefox.
|
||||
See: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
|
||||
Therefore, navigator.clipboard.readText() is used on Firefox.
|
||||
|
||||
navigator.clipboard.readText() can't be used in Chrome for two reasons:
|
||||
* Requires page to be focused, else it rejects with an exception.
|
||||
* When the page is focused, Chrome will request clipboard permission, despite already
|
||||
being an extension with clipboard permissions. It effectively asks for the
|
||||
non-extension permission for clipboard access.
|
||||
*/
|
||||
if (this._isFirefox() && !useRichText) {
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch (e) {
|
||||
// Error is undefined, due to permissions
|
||||
throw new Error('Cannot read clipboard text; check extension permissions');
|
||||
}
|
||||
}
|
||||
|
||||
const document = this._document;
|
||||
if (document === null) {
|
||||
throw new Error('Clipboard reading not supported in this context');
|
||||
}
|
||||
|
||||
if (useRichText) {
|
||||
const target = this._getRichContentPasteTarget();
|
||||
target.focus();
|
||||
document.execCommand('paste');
|
||||
const result = /** @type {string} */ (target.textContent);
|
||||
this._clearRichContent(target);
|
||||
return result;
|
||||
} else {
|
||||
const target = this._getPasteTarget();
|
||||
target.value = '';
|
||||
target.focus();
|
||||
document.execCommand('paste');
|
||||
const result = target.value;
|
||||
target.value = '';
|
||||
return (typeof result === 'string' ? result : '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the first image in the clipboard.
|
||||
* @returns {Promise<?string>} A string containing a data URL of the image file, or null if no image was found.
|
||||
* @throws {Error} Error if not supported.
|
||||
*/
|
||||
async getImage() {
|
||||
// See browser-specific notes in getText
|
||||
if (
|
||||
this._isFirefox() &&
|
||||
typeof navigator.clipboard !== 'undefined' &&
|
||||
typeof navigator.clipboard.read === 'function'
|
||||
) {
|
||||
// This function is behind the Firefox flag: dom.events.asyncClipboard.read
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/read#browser_compatibility
|
||||
let items;
|
||||
try {
|
||||
items = await navigator.clipboard.read();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
for (const type of item.types) {
|
||||
if (!getFileExtensionFromImageMediaType(type)) { continue; }
|
||||
try {
|
||||
const blob = await item.getType(type);
|
||||
return await this._readFileAsDataURL(blob);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const document = this._document;
|
||||
if (document === null) {
|
||||
throw new Error('Clipboard reading not supported in this context');
|
||||
}
|
||||
|
||||
const target = this._getRichContentPasteTarget();
|
||||
target.focus();
|
||||
document.execCommand('paste');
|
||||
const image = target.querySelector('img[src^="data:"]');
|
||||
const result = (image !== null ? image.getAttribute('src') : null);
|
||||
this._clearRichContent(target);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isFirefox() {
|
||||
return (this._browser === 'firefox' || this._browser === 'firefox-mobile');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob} file
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
_readFileAsDataURL(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(/** @type {string} */ (reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLTextAreaElement}
|
||||
*/
|
||||
_getPasteTarget() {
|
||||
if (this._pasteTarget === null) {
|
||||
this._pasteTarget = /** @type {HTMLTextAreaElement} */ (this._findPasteTarget(this._pasteTargetSelector));
|
||||
}
|
||||
return this._pasteTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_getRichContentPasteTarget() {
|
||||
if (this._richContentPasteTarget === null) {
|
||||
this._richContentPasteTarget = /** @type {HTMLElement} */ (this._findPasteTarget(this._richContentPasteTargetSelector));
|
||||
}
|
||||
return this._richContentPasteTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {Element} T
|
||||
* @param {?string} selector
|
||||
* @returns {T}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_findPasteTarget(selector) {
|
||||
if (selector === null) { throw new Error('Invalid selector'); }
|
||||
const target = this._document !== null ? this._document.querySelector(selector) : null;
|
||||
if (target === null) { throw new Error('Clipboard paste target does not exist'); }
|
||||
return /** @type {T} */ (target);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
_clearRichContent(element) {
|
||||
for (const image of element.querySelectorAll('img')) {
|
||||
image.removeAttribute('src');
|
||||
image.removeAttribute('srcset');
|
||||
}
|
||||
element.textContent = '';
|
||||
}
|
||||
}
|
||||
487
vendor/yomitan/js/comm/cross-frame-api.js
vendored
Normal file
487
vendor/yomitan/js/comm/cross-frame-api.js
vendored
Normal file
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
* 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 {extendApiMap, invokeApiMapHandler} from '../core/api-map.js';
|
||||
import {EventDispatcher} from '../core/event-dispatcher.js';
|
||||
import {EventListenerCollection} from '../core/event-listener-collection.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
import {parseJson} from '../core/json.js';
|
||||
import {log} from '../core/log.js';
|
||||
import {safePerformance} from '../core/safe-performance.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('cross-frame-api').CrossFrameAPIPortEvents>
|
||||
*/
|
||||
export class CrossFrameAPIPort extends EventDispatcher {
|
||||
/**
|
||||
* @param {number} otherTabId
|
||||
* @param {number} otherFrameId
|
||||
* @param {chrome.runtime.Port} port
|
||||
* @param {import('cross-frame-api').ApiMap} apiMap
|
||||
*/
|
||||
constructor(otherTabId, otherFrameId, port, apiMap) {
|
||||
super();
|
||||
/** @type {number} */
|
||||
this._otherTabId = otherTabId;
|
||||
/** @type {number} */
|
||||
this._otherFrameId = otherFrameId;
|
||||
/** @type {?chrome.runtime.Port} */
|
||||
this._port = port;
|
||||
/** @type {import('cross-frame-api').ApiMap} */
|
||||
this._apiMap = apiMap;
|
||||
/** @type {Map<number, import('cross-frame-api').Invocation>} */
|
||||
this._activeInvocations = new Map();
|
||||
/** @type {number} */
|
||||
this._invocationId = 0;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get otherTabId() {
|
||||
return this._otherTabId;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get otherFrameId() {
|
||||
return this._otherFrameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {Error}
|
||||
*/
|
||||
prepare() {
|
||||
if (this._port === null) { throw new Error('Invalid state'); }
|
||||
this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this));
|
||||
this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this));
|
||||
this._eventListeners.addEventListener(window, 'pageshow', this._onPageShow.bind(this));
|
||||
this._eventListeners.addEventListener(document, 'resume', this._onResume.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('cross-frame-api').ApiNames} TName
|
||||
* @param {TName} action
|
||||
* @param {import('cross-frame-api').ApiParams<TName>} params
|
||||
* @param {number} ackTimeout
|
||||
* @param {number} responseTimeout
|
||||
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
|
||||
*/
|
||||
invoke(action, params, ackTimeout, responseTimeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this._port === null) {
|
||||
reject(new Error(`Port is disconnected (${action})`));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this._invocationId++;
|
||||
/** @type {import('cross-frame-api').Invocation} */
|
||||
const invocation = {
|
||||
id,
|
||||
resolve,
|
||||
reject,
|
||||
responseTimeout,
|
||||
action,
|
||||
ack: false,
|
||||
timer: null,
|
||||
};
|
||||
this._activeInvocations.set(id, invocation);
|
||||
|
||||
if (ackTimeout !== null) {
|
||||
try {
|
||||
invocation.timer = setTimeout(() => this._onError(id, 'Acknowledgement timeout'), ackTimeout);
|
||||
} catch (e) {
|
||||
this._onError(id, 'Failed to set timeout');
|
||||
return;
|
||||
}
|
||||
}
|
||||
safePerformance.mark(`cross-frame-api:invoke:${action}`);
|
||||
try {
|
||||
this._port.postMessage(/** @type {import('cross-frame-api').InvokeMessage} */ ({type: 'invoke', id, data: {action, params}}));
|
||||
} catch (e) {
|
||||
this._onError(id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** */
|
||||
disconnect() {
|
||||
this._onDisconnect();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onResume(e) {
|
||||
// Page Resumed after being frozen
|
||||
log.log('Yomitan cross frame reset. Resuming after page frozen.', e);
|
||||
this._onDisconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PageTransitionEvent} e
|
||||
*/
|
||||
_onPageShow(e) {
|
||||
// Page restored from BFCache
|
||||
if (e.persisted) {
|
||||
log.log('Yomitan cross frame reset. Page restored from BFCache.', e);
|
||||
this._onDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_onDisconnect() {
|
||||
if (this._port === null) { return; }
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._port = null;
|
||||
for (const id of this._activeInvocations.keys()) {
|
||||
this._onError(id, 'Disconnected');
|
||||
}
|
||||
this.trigger('disconnect', this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('cross-frame-api').Message} details
|
||||
*/
|
||||
_onMessage(details) {
|
||||
const {type, id} = details;
|
||||
switch (type) {
|
||||
case 'invoke':
|
||||
this._onInvoke(id, details.data);
|
||||
break;
|
||||
case 'ack':
|
||||
this._onAck(id);
|
||||
break;
|
||||
case 'result':
|
||||
this._onResult(id, details.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Response handlers
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
_onAck(id) {
|
||||
const invocation = this._activeInvocations.get(id);
|
||||
if (typeof invocation === 'undefined') {
|
||||
log.warn(new Error(`Request ${id} not found for acknowledgement`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (invocation.ack) {
|
||||
this._onError(id, `Request ${id} already acknowledged`);
|
||||
return;
|
||||
}
|
||||
|
||||
invocation.ack = true;
|
||||
|
||||
if (invocation.timer !== null) {
|
||||
clearTimeout(invocation.timer);
|
||||
invocation.timer = null;
|
||||
}
|
||||
|
||||
const responseTimeout = invocation.responseTimeout;
|
||||
if (responseTimeout !== null) {
|
||||
try {
|
||||
invocation.timer = setTimeout(() => this._onError(id, 'Response timeout'), responseTimeout);
|
||||
} catch (e) {
|
||||
this._onError(id, 'Failed to set timeout');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {import('core').Response<import('cross-frame-api').ApiReturnAny>} data
|
||||
*/
|
||||
_onResult(id, data) {
|
||||
const invocation = this._activeInvocations.get(id);
|
||||
if (typeof invocation === 'undefined') {
|
||||
log.warn(new Error(`Request ${id} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invocation.ack) {
|
||||
this._onError(id, `Request ${id} not acknowledged`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._activeInvocations.delete(id);
|
||||
|
||||
if (invocation.timer !== null) {
|
||||
clearTimeout(invocation.timer);
|
||||
invocation.timer = null;
|
||||
}
|
||||
|
||||
const error = data.error;
|
||||
if (typeof error !== 'undefined') {
|
||||
invocation.reject(ExtensionError.deserialize(error));
|
||||
} else {
|
||||
invocation.resolve(data.result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {unknown} errorOrMessage
|
||||
*/
|
||||
_onError(id, errorOrMessage) {
|
||||
const invocation = this._activeInvocations.get(id);
|
||||
if (typeof invocation === 'undefined') { return; }
|
||||
|
||||
const error = errorOrMessage instanceof Error ? errorOrMessage : new Error(`${errorOrMessage} (${invocation.action})`);
|
||||
|
||||
this._activeInvocations.delete(id);
|
||||
if (invocation.timer !== null) {
|
||||
clearTimeout(invocation.timer);
|
||||
invocation.timer = null;
|
||||
}
|
||||
invocation.reject(error);
|
||||
}
|
||||
|
||||
// Invocation
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {import('cross-frame-api').ApiMessageAny} details
|
||||
*/
|
||||
_onInvoke(id, {action, params}) {
|
||||
this._sendAck(id);
|
||||
invokeApiMapHandler(
|
||||
this._apiMap,
|
||||
action,
|
||||
params,
|
||||
[],
|
||||
(data) => this._sendResult(id, data),
|
||||
() => this._sendError(id, new Error(`Unknown action: ${action}`)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('cross-frame-api').Message} data
|
||||
*/
|
||||
_sendResponse(data) {
|
||||
if (this._port === null) { return; }
|
||||
try {
|
||||
this._port.postMessage(data);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
_sendAck(id) {
|
||||
this._sendResponse({type: 'ack', id});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {import('core').Response<import('cross-frame-api').ApiReturnAny>} data
|
||||
*/
|
||||
_sendResult(id, data) {
|
||||
this._sendResponse({type: 'result', id, data});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {Error} error
|
||||
*/
|
||||
_sendError(id, error) {
|
||||
this._sendResponse({type: 'result', id, data: {error: ExtensionError.serialize(error)}});
|
||||
}
|
||||
}
|
||||
|
||||
export class CrossFrameAPI {
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
* @param {?number} tabId
|
||||
* @param {?number} frameId
|
||||
*/
|
||||
constructor(api, tabId, frameId) {
|
||||
/** @type {import('../comm/api.js').API} */
|
||||
this._api = api;
|
||||
/** @type {number} */
|
||||
this._ackTimeout = 3000; // 3 seconds
|
||||
/** @type {number} */
|
||||
this._responseTimeout = 10000; // 10 seconds
|
||||
/** @type {Map<number, Map<number, CrossFrameAPIPort>>} */
|
||||
this._commPorts = new Map();
|
||||
/** @type {import('cross-frame-api').ApiMap} */
|
||||
this._apiMap = new Map();
|
||||
/** @type {(port: CrossFrameAPIPort) => void} */
|
||||
this._onDisconnectBind = this._onDisconnect.bind(this);
|
||||
/** @type {?number} */
|
||||
this._tabId = tabId;
|
||||
/** @type {?number} */
|
||||
this._frameId = frameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {?number}
|
||||
*/
|
||||
get tabId() {
|
||||
return this._tabId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {?number}
|
||||
*/
|
||||
get frameId() {
|
||||
return this._frameId;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('cross-frame-api').ApiNames} TName
|
||||
* @param {number} targetFrameId
|
||||
* @param {TName} action
|
||||
* @param {import('cross-frame-api').ApiParams<TName>} params
|
||||
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
|
||||
*/
|
||||
invoke(targetFrameId, action, params) {
|
||||
return this.invokeTab(null, targetFrameId, action, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('cross-frame-api').ApiNames} TName
|
||||
* @param {?number} targetTabId
|
||||
* @param {number} targetFrameId
|
||||
* @param {TName} action
|
||||
* @param {import('cross-frame-api').ApiParams<TName>} params
|
||||
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
|
||||
*/
|
||||
async invokeTab(targetTabId, targetFrameId, action, params) {
|
||||
if (typeof targetTabId !== 'number') {
|
||||
targetTabId = this._tabId;
|
||||
if (typeof targetTabId !== 'number') {
|
||||
throw new Error('Unknown target tab id for invocation');
|
||||
}
|
||||
}
|
||||
const commPort = await this._getOrCreateCommPort(targetTabId, targetFrameId);
|
||||
return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('cross-frame-api').ApiMapInit} handlers
|
||||
*/
|
||||
registerHandlers(handlers) {
|
||||
extendApiMap(this._apiMap, handlers);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {chrome.runtime.Port} port
|
||||
*/
|
||||
_onConnect(port) {
|
||||
try {
|
||||
/** @type {import('cross-frame-api').PortDetails} */
|
||||
let details;
|
||||
try {
|
||||
details = parseJson(port.name);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (details.name !== 'cross-frame-communication-port') { return; }
|
||||
|
||||
const otherTabId = details.otherTabId;
|
||||
const otherFrameId = details.otherFrameId;
|
||||
this._setupCommPort(otherTabId, otherFrameId, port);
|
||||
} catch (e) {
|
||||
port.disconnect();
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CrossFrameAPIPort} commPort
|
||||
*/
|
||||
_onDisconnect(commPort) {
|
||||
commPort.off('disconnect', this._onDisconnectBind);
|
||||
const {otherTabId, otherFrameId} = commPort;
|
||||
const tabPorts = this._commPorts.get(otherTabId);
|
||||
if (typeof tabPorts !== 'undefined') {
|
||||
tabPorts.delete(otherFrameId);
|
||||
if (tabPorts.size === 0) {
|
||||
this._commPorts.delete(otherTabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} otherTabId
|
||||
* @param {number} otherFrameId
|
||||
* @returns {Promise<CrossFrameAPIPort>}
|
||||
*/
|
||||
async _getOrCreateCommPort(otherTabId, otherFrameId) {
|
||||
const tabPorts = this._commPorts.get(otherTabId);
|
||||
if (typeof tabPorts !== 'undefined') {
|
||||
const commPort = tabPorts.get(otherFrameId);
|
||||
if (typeof commPort !== 'undefined') {
|
||||
return commPort;
|
||||
}
|
||||
}
|
||||
return await this._createCommPort(otherTabId, otherFrameId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} otherTabId
|
||||
* @param {number} otherFrameId
|
||||
* @returns {Promise<CrossFrameAPIPort>}
|
||||
*/
|
||||
async _createCommPort(otherTabId, otherFrameId) {
|
||||
await this._api.openCrossFramePort(otherTabId, otherFrameId);
|
||||
|
||||
const tabPorts = this._commPorts.get(otherTabId);
|
||||
if (typeof tabPorts !== 'undefined') {
|
||||
const commPort = tabPorts.get(otherFrameId);
|
||||
if (typeof commPort !== 'undefined') {
|
||||
return commPort;
|
||||
}
|
||||
}
|
||||
throw new Error('Comm port didn\'t open');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} otherTabId
|
||||
* @param {number} otherFrameId
|
||||
* @param {chrome.runtime.Port} port
|
||||
* @returns {CrossFrameAPIPort}
|
||||
*/
|
||||
_setupCommPort(otherTabId, otherFrameId, port) {
|
||||
const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._apiMap);
|
||||
let tabPorts = this._commPorts.get(otherTabId);
|
||||
if (typeof tabPorts === 'undefined') {
|
||||
tabPorts = new Map();
|
||||
this._commPorts.set(otherTabId, tabPorts);
|
||||
}
|
||||
tabPorts.set(otherFrameId, commPort);
|
||||
commPort.prepare();
|
||||
commPort.on('disconnect', this._onDisconnectBind);
|
||||
return commPort;
|
||||
}
|
||||
}
|
||||
329
vendor/yomitan/js/comm/frame-ancestry-handler.js
vendored
Normal file
329
vendor/yomitan/js/comm/frame-ancestry-handler.js
vendored
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* 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 {generateId} from '../core/utilities.js';
|
||||
|
||||
/**
|
||||
* This class is used to return the ancestor frame IDs for the current frame.
|
||||
* This is a workaround to using the `webNavigation.getAllFrames` API, which
|
||||
* would require an additional permission that is otherwise unnecessary.
|
||||
* It is also used to track the correlation between child frame elements and their IDs.
|
||||
*/
|
||||
export class FrameAncestryHandler {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
* @param {import('../comm/cross-frame-api.js').CrossFrameAPI} crossFrameApi
|
||||
*/
|
||||
constructor(crossFrameApi) {
|
||||
/** @type {import('../comm/cross-frame-api.js').CrossFrameAPI} */
|
||||
this._crossFrameApi = crossFrameApi;
|
||||
/** @type {boolean} */
|
||||
this._isPrepared = false;
|
||||
/** @type {string} */
|
||||
this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo';
|
||||
/** @type {?Promise<number[]>} */
|
||||
this._getFrameAncestryInfoPromise = null;
|
||||
/** @type {Map<number, {window: Window, frameElement: ?(undefined|Element)}>} */
|
||||
this._childFrameMap = new Map();
|
||||
/** @type {Map<string, import('frame-ancestry-handler').ResponseHandler>} */
|
||||
this._responseHandlers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes event event listening.
|
||||
*/
|
||||
prepare() {
|
||||
if (this._isPrepared) { return; }
|
||||
window.addEventListener('message', this._onWindowMessage.bind(this), false);
|
||||
this._crossFrameApi.registerHandlers([
|
||||
['frameAncestryHandlerRequestFrameInfoResponse', this._onFrameAncestryHandlerRequestFrameInfoResponse.bind(this)],
|
||||
]);
|
||||
this._isPrepared = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not this frame is the root frame in the tab.
|
||||
* @returns {boolean} `true` if it is the root, otherwise `false`.
|
||||
*/
|
||||
isRootFrame() {
|
||||
return (window === window.parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the frame ancestry information for the current frame. If the frame is the
|
||||
* root frame, an empty array is returned. Otherwise, an array of frame IDs is returned,
|
||||
* starting from the nearest ancestor.
|
||||
* @returns {Promise<number[]>} An array of frame IDs corresponding to the ancestors of the current frame.
|
||||
*/
|
||||
async getFrameAncestryInfo() {
|
||||
if (this._getFrameAncestryInfoPromise === null) {
|
||||
this._getFrameAncestryInfoPromise = this._getFrameAncestryInfo(5000);
|
||||
}
|
||||
return await this._getFrameAncestryInfoPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the frame element of a child frame given a frame ID.
|
||||
* For this function to work, the `getFrameAncestryInfo` function needs to have
|
||||
* been invoked previously.
|
||||
* @param {number} frameId The frame ID of the child frame to get.
|
||||
* @returns {?Element} The element corresponding to the frame with ID `frameId`, otherwise `null`.
|
||||
*/
|
||||
getChildFrameElement(frameId) {
|
||||
const frameInfo = this._childFrameMap.get(frameId);
|
||||
if (typeof frameInfo === 'undefined') { return null; }
|
||||
|
||||
let {frameElement} = frameInfo;
|
||||
if (typeof frameElement === 'undefined') {
|
||||
frameElement = this._findFrameElementWithContentWindow(frameInfo.window);
|
||||
frameInfo.frameElement = frameElement;
|
||||
}
|
||||
|
||||
return frameElement;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {number} [timeout]
|
||||
* @returns {Promise<number[]>}
|
||||
*/
|
||||
_getFrameAncestryInfo(timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const {frameId} = this._crossFrameApi;
|
||||
const targetWindow = window.parent;
|
||||
if (frameId === null || window === targetWindow) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueId = generateId(16);
|
||||
let nonce = generateId(16);
|
||||
/** @type {number[]} */
|
||||
const results = [];
|
||||
/** @type {?import('core').Timeout} */
|
||||
let timer = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
this._removeResponseHandler(uniqueId);
|
||||
};
|
||||
/** @type {import('frame-ancestry-handler').ResponseHandler} */
|
||||
const onMessage = (params) => {
|
||||
if (params.nonce !== nonce) { return null; }
|
||||
|
||||
// Add result
|
||||
results.push(params.frameId);
|
||||
nonce = generateId(16);
|
||||
|
||||
if (!params.more) {
|
||||
// Cleanup
|
||||
cleanup();
|
||||
|
||||
// Finish
|
||||
resolve(results);
|
||||
}
|
||||
return {nonce};
|
||||
};
|
||||
const onTimeout = () => {
|
||||
timer = null;
|
||||
cleanup();
|
||||
reject(new Error(`Request for parent frame ID timed out after ${timeout}ms`));
|
||||
};
|
||||
const resetTimeout = () => {
|
||||
if (timer !== null) { clearTimeout(timer); }
|
||||
timer = setTimeout(onTimeout, timeout);
|
||||
};
|
||||
|
||||
// Start
|
||||
this._addResponseHandler(uniqueId, onMessage);
|
||||
resetTimeout();
|
||||
this._requestFrameInfo(targetWindow, frameId, frameId, uniqueId, nonce);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<unknown>} event
|
||||
*/
|
||||
_onWindowMessage(event) {
|
||||
const source = /** @type {?Window} */ (event.source);
|
||||
if (source === null || source === window || source.parent !== window) { return; }
|
||||
|
||||
const {data} = event;
|
||||
if (typeof data !== 'object' || data === null) { return; }
|
||||
|
||||
const {action} = /** @type {import('core').SerializableObject} */ (data);
|
||||
if (action !== this._requestMessageId) { return; }
|
||||
|
||||
const {params} = /** @type {import('core').SerializableObject} */ (data);
|
||||
if (typeof params !== 'object' || params === null) { return; }
|
||||
|
||||
void this._onRequestFrameInfo(/** @type {import('core').SerializableObject} */ (params), source);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('core').SerializableObject} params
|
||||
* @param {Window} source
|
||||
*/
|
||||
async _onRequestFrameInfo(params, source) {
|
||||
try {
|
||||
let {originFrameId, childFrameId, uniqueId, nonce} = params;
|
||||
if (
|
||||
typeof originFrameId !== 'number' ||
|
||||
typeof childFrameId !== 'number' ||
|
||||
!this._isNonNegativeInteger(originFrameId) ||
|
||||
typeof uniqueId !== 'string' ||
|
||||
typeof nonce !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {frameId} = this._crossFrameApi;
|
||||
if (frameId === null) { return; }
|
||||
|
||||
const {parent} = window;
|
||||
const more = (window !== parent);
|
||||
|
||||
try {
|
||||
const response = await this._crossFrameApi.invoke(originFrameId, 'frameAncestryHandlerRequestFrameInfoResponse', {uniqueId, frameId, nonce, more});
|
||||
if (response === null) { return; }
|
||||
const nonce2 = response.nonce;
|
||||
if (typeof nonce2 !== 'string') { return; }
|
||||
nonce = nonce2;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._childFrameMap.has(childFrameId)) {
|
||||
this._childFrameMap.set(childFrameId, {window: source, frameElement: void 0});
|
||||
}
|
||||
|
||||
if (more) {
|
||||
this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, /** @type {string} */ (nonce));
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Window} targetWindow
|
||||
* @param {number} originFrameId
|
||||
* @param {number} childFrameId
|
||||
* @param {string} uniqueId
|
||||
* @param {string} nonce
|
||||
*/
|
||||
_requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) {
|
||||
targetWindow.postMessage({
|
||||
action: this._requestMessageId,
|
||||
params: {originFrameId, childFrameId, uniqueId, nonce},
|
||||
}, '*');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isNonNegativeInteger(value) {
|
||||
return (
|
||||
Number.isFinite(value) &&
|
||||
value >= 0 &&
|
||||
Math.floor(value) === value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Window} contentWindow
|
||||
* @returns {?Element}
|
||||
*/
|
||||
_findFrameElementWithContentWindow(contentWindow) {
|
||||
// Check frameElement, for non-null same-origin frames
|
||||
try {
|
||||
const {frameElement} = contentWindow;
|
||||
if (frameElement !== null) { return frameElement; }
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
// Check frames
|
||||
const frameTypes = ['iframe', 'frame', 'object'];
|
||||
for (const frameType of frameTypes) {
|
||||
for (const frame of /** @type {HTMLCollectionOf<import('extension').HtmlElementWithContentWindow>} */ (document.getElementsByTagName(frameType))) {
|
||||
if (frame.contentWindow === contentWindow) {
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for shadow roots
|
||||
/** @type {Node[]} */
|
||||
const rootElements = [document.documentElement];
|
||||
while (rootElements.length > 0) {
|
||||
const rootElement = /** @type {Node} */ (rootElements.shift());
|
||||
const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
|
||||
while (walker.nextNode()) {
|
||||
const element = /** @type {Element} */ (walker.currentNode);
|
||||
|
||||
// @ts-expect-error - this is more simple to elide any type checks or casting
|
||||
if (element.contentWindow === contentWindow) {
|
||||
return element;
|
||||
}
|
||||
|
||||
/** @type {?ShadowRoot|undefined} */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const shadowRoot = (
|
||||
element.shadowRoot ||
|
||||
// @ts-expect-error - openOrClosedShadowRoot is available to Firefox 63+ for WebExtensions
|
||||
element.openOrClosedShadowRoot
|
||||
);
|
||||
if (shadowRoot) {
|
||||
rootElements.push(shadowRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {import('frame-ancestry-handler').ResponseHandler} handler
|
||||
* @throws {Error}
|
||||
*/
|
||||
_addResponseHandler(id, handler) {
|
||||
if (this._responseHandlers.has(id)) { throw new Error('Identifier already used'); }
|
||||
this._responseHandlers.set(id, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
_removeResponseHandler(id) {
|
||||
this._responseHandlers.delete(id);
|
||||
}
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'frameAncestryHandlerRequestFrameInfoResponse'>} */
|
||||
_onFrameAncestryHandlerRequestFrameInfoResponse(params) {
|
||||
const handler = this._responseHandlers.get(params.uniqueId);
|
||||
return typeof handler !== 'undefined' ? handler(params) : null;
|
||||
}
|
||||
}
|
||||
225
vendor/yomitan/js/comm/frame-client.js
vendored
Normal file
225
vendor/yomitan/js/comm/frame-client.js
vendored
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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 {isObjectNotArray} from '../core/object-utilities.js';
|
||||
import {deferPromise, generateId} from '../core/utilities.js';
|
||||
|
||||
export class FrameClient {
|
||||
constructor() {
|
||||
/** @type {?string} */
|
||||
this._secret = null;
|
||||
/** @type {?string} */
|
||||
this._token = null;
|
||||
/** @type {?number} */
|
||||
this._frameId = null;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get frameId() {
|
||||
if (this._frameId === null) { throw new Error('Not connected'); }
|
||||
return this._frameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('extension').HtmlElementWithContentWindow} frame
|
||||
* @param {string} targetOrigin
|
||||
* @param {number} hostFrameId
|
||||
* @param {import('frame-client').SetupFrameFunction} setupFrame
|
||||
* @param {number} [timeout]
|
||||
*/
|
||||
async connect(frame, targetOrigin, hostFrameId, setupFrame, timeout = 10000) {
|
||||
const {secret, token, frameId} = await this._connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout);
|
||||
this._secret = secret;
|
||||
this._token = token;
|
||||
this._frameId = frameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isConnected() {
|
||||
return (this._secret !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {T} data
|
||||
* @returns {import('frame-client').Message<T>}
|
||||
* @throws {Error}
|
||||
*/
|
||||
createMessage(data) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return {
|
||||
token: /** @type {string} */ (this._token),
|
||||
secret: /** @type {string} */ (this._secret),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('extension').HtmlElementWithContentWindow} frame
|
||||
* @param {string} targetOrigin
|
||||
* @param {number} hostFrameId
|
||||
* @param {(frame: import('extension').HtmlElementWithContentWindow) => void} setupFrame
|
||||
* @param {number} timeout
|
||||
* @returns {Promise<{secret: string, token: string, frameId: number}>}
|
||||
*/
|
||||
_connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
/** @type {Map<string, string>} */
|
||||
const tokenMap = new Map();
|
||||
/** @type {?import('core').Timeout} */
|
||||
let timer = null;
|
||||
const deferPromiseDetails = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise());
|
||||
const frameLoadedPromise = deferPromiseDetails.promise;
|
||||
let frameLoadedResolve = /** @type {?() => void} */ (deferPromiseDetails.resolve);
|
||||
let frameLoadedReject = /** @type {?(reason?: import('core').RejectionReason) => void} */ (deferPromiseDetails.reject);
|
||||
|
||||
/**
|
||||
* @param {string} action
|
||||
* @param {import('core').SerializableObject} params
|
||||
* @throws {Error}
|
||||
*/
|
||||
const postMessage = (action, params) => {
|
||||
const contentWindow = frame.contentWindow;
|
||||
if (contentWindow === null) { throw new Error('Frame missing content window'); }
|
||||
|
||||
let validOrigin = true;
|
||||
try {
|
||||
validOrigin = (contentWindow.location.origin === targetOrigin);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
if (!validOrigin) { throw new Error('Unexpected frame origin'); }
|
||||
|
||||
contentWindow.postMessage({action, params}, targetOrigin);
|
||||
};
|
||||
|
||||
/** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */
|
||||
const onMessage = (message) => {
|
||||
void onMessageInner(message);
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('application').ApiMessageAny} message
|
||||
*/
|
||||
const onMessageInner = async (message) => {
|
||||
try {
|
||||
if (!isObjectNotArray(message)) { return; }
|
||||
const {action, params} = message;
|
||||
if (!isObjectNotArray(params)) { return; }
|
||||
await frameLoadedPromise;
|
||||
if (timer === null) { return; } // Done
|
||||
|
||||
switch (action) {
|
||||
case 'frameEndpointReady':
|
||||
{
|
||||
const {secret} = params;
|
||||
const token = generateId(16);
|
||||
tokenMap.set(secret, token);
|
||||
postMessage('frameEndpointConnect', {secret, token, hostFrameId});
|
||||
}
|
||||
break;
|
||||
case 'frameEndpointConnected':
|
||||
{
|
||||
const {secret, token} = params;
|
||||
const frameId = message.frameId;
|
||||
const token2 = tokenMap.get(secret);
|
||||
if (typeof token2 !== 'undefined' && token === token2 && typeof frameId === 'number') {
|
||||
cleanup();
|
||||
resolve({secret, token, frameId});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoad = () => {
|
||||
if (frameLoadedResolve === null) {
|
||||
cleanup();
|
||||
reject(new Error('Unexpected load event'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (FrameClient.isFrameAboutBlank(frame)) {
|
||||
return;
|
||||
}
|
||||
|
||||
frameLoadedResolve();
|
||||
frameLoadedResolve = null;
|
||||
frameLoadedReject = null;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer === null) { return; } // Done
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
|
||||
frameLoadedResolve = null;
|
||||
if (frameLoadedReject !== null) {
|
||||
frameLoadedReject(new Error('Terminated'));
|
||||
frameLoadedReject = null;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
frame.removeEventListener('load', onLoad);
|
||||
};
|
||||
|
||||
// Start
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Timeout'));
|
||||
}, timeout);
|
||||
|
||||
chrome.runtime.onMessage.addListener(onMessage);
|
||||
frame.addEventListener('load', onLoad);
|
||||
|
||||
// Prevent unhandled rejections
|
||||
frameLoadedPromise.catch(() => {}); // NOP
|
||||
|
||||
try {
|
||||
setupFrame(frame);
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('extension').HtmlElementWithContentWindow} frame
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isFrameAboutBlank(frame) {
|
||||
try {
|
||||
const contentDocument = frame.contentDocument;
|
||||
if (contentDocument === null) { return false; }
|
||||
const url = contentDocument.location.href;
|
||||
return /^about:blank(?:[#?]|$)/.test(url);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
vendor/yomitan/js/comm/frame-endpoint.js
vendored
Normal file
109
vendor/yomitan/js/comm/frame-endpoint.js
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 {log} from '../core/log.js';
|
||||
import {generateId} from '../core/utilities.js';
|
||||
|
||||
export class FrameEndpoint {
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
*/
|
||||
constructor(api) {
|
||||
/** @type {import('../comm/api.js').API} */
|
||||
this._api = api;
|
||||
/** @type {string} */
|
||||
this._secret = generateId(16);
|
||||
/** @type {?string} */
|
||||
this._token = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {boolean} */
|
||||
this._eventListenersSetup = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
signal() {
|
||||
if (!this._eventListenersSetup) {
|
||||
this._eventListeners.addEventListener(window, 'message', this._onMessage.bind(this), false);
|
||||
this._eventListenersSetup = true;
|
||||
}
|
||||
/** @type {import('frame-client').FrameEndpointReadyDetails} */
|
||||
const details = {secret: this._secret};
|
||||
void this._api.broadcastTab({action: 'frameEndpointReady', params: details});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} message
|
||||
* @returns {boolean}
|
||||
*/
|
||||
authenticate(message) {
|
||||
return (
|
||||
this._token !== null &&
|
||||
typeof message === 'object' && message !== null &&
|
||||
this._token === /** @type {import('core').SerializableObject} */ (message).token &&
|
||||
this._secret === /** @type {import('core').SerializableObject} */ (message).secret
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<unknown>} event
|
||||
*/
|
||||
_onMessage(event) {
|
||||
if (this._token !== null) { return; } // Already initialized
|
||||
|
||||
const {data} = event;
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
log.error('Invalid message');
|
||||
return;
|
||||
}
|
||||
|
||||
const {action} = /** @type {import('core').SerializableObject} */ (data);
|
||||
if (action !== 'frameEndpointConnect') {
|
||||
log.error('Invalid action');
|
||||
return;
|
||||
}
|
||||
|
||||
const {params} = /** @type {import('core').SerializableObject} */ (data);
|
||||
if (typeof params !== 'object' || params === null) {
|
||||
log.error('Invalid data');
|
||||
return;
|
||||
}
|
||||
|
||||
const {secret} = /** @type {import('core').SerializableObject} */ (params);
|
||||
if (secret !== this._secret) {
|
||||
log.error('Invalid authentication');
|
||||
return;
|
||||
}
|
||||
|
||||
const {token, hostFrameId} = /** @type {import('core').SerializableObject} */ (params);
|
||||
if (typeof token !== 'string' || typeof hostFrameId !== 'number') {
|
||||
log.error('Invalid target');
|
||||
return;
|
||||
}
|
||||
|
||||
this._token = token;
|
||||
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
/** @type {import('frame-client').FrameEndpointConnectedDetails} */
|
||||
const details = {secret, token};
|
||||
void this._api.sendMessageToFrame(hostFrameId, {action: 'frameEndpointConnected', params: details});
|
||||
}
|
||||
}
|
||||
89
vendor/yomitan/js/comm/frame-offset-forwarder.js
vendored
Normal file
89
vendor/yomitan/js/comm/frame-offset-forwarder.js
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 {FrameAncestryHandler} from './frame-ancestry-handler.js';
|
||||
|
||||
export class FrameOffsetForwarder {
|
||||
/**
|
||||
* @param {import('../comm/cross-frame-api.js').CrossFrameAPI} crossFrameApi
|
||||
*/
|
||||
constructor(crossFrameApi) {
|
||||
/** @type {import('../comm/cross-frame-api.js').CrossFrameAPI} */
|
||||
this._crossFrameApi = crossFrameApi;
|
||||
/** @type {FrameAncestryHandler} */
|
||||
this._frameAncestryHandler = new FrameAncestryHandler(crossFrameApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
prepare() {
|
||||
this._frameAncestryHandler.prepare();
|
||||
this._crossFrameApi.registerHandlers([
|
||||
['frameOffsetForwarderGetChildFrameRect', this._onMessageGetChildFrameRect.bind(this)],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<?[x: number, y: number]>}
|
||||
*/
|
||||
async getOffset() {
|
||||
if (this._frameAncestryHandler.isRootFrame()) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
const {frameId} = this._crossFrameApi;
|
||||
if (frameId === null) { return null; }
|
||||
|
||||
try {
|
||||
const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo();
|
||||
|
||||
let childFrameId = frameId;
|
||||
/** @type {Promise<?import('frame-offset-forwarder').ChildFrameRect>[]} */
|
||||
const promises = [];
|
||||
for (const ancestorFrameId of ancestorFrameIds) {
|
||||
promises.push(this._crossFrameApi.invoke(ancestorFrameId, 'frameOffsetForwarderGetChildFrameRect', {frameId: childFrameId}));
|
||||
childFrameId = ancestorFrameId;
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const result of results) {
|
||||
if (result === null) { return null; }
|
||||
x += result.x;
|
||||
y += result.y;
|
||||
}
|
||||
return [x, y];
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** @type {import('cross-frame-api').ApiHandler<'frameOffsetForwarderGetChildFrameRect'>} */
|
||||
_onMessageGetChildFrameRect({frameId}) {
|
||||
const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId);
|
||||
if (frameElement === null) { return null; }
|
||||
|
||||
const {left, top, width, height} = frameElement.getBoundingClientRect();
|
||||
return {x: left, y: top, width, height};
|
||||
}
|
||||
}
|
||||
286
vendor/yomitan/js/comm/mecab.js
vendored
Normal file
286
vendor/yomitan/js/comm/mecab.js
vendored
Normal file
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* 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 {EventListenerCollection} from '../core/event-listener-collection.js';
|
||||
import {toError} from '../core/to-error.js';
|
||||
|
||||
/**
|
||||
* This class is used to connect Yomitan to a native component that is
|
||||
* used to parse text into individual terms.
|
||||
*/
|
||||
export class Mecab {
|
||||
/**
|
||||
* Creates a new instance of the class.
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {?chrome.runtime.Port} */
|
||||
this._port = null;
|
||||
/** @type {number} */
|
||||
this._sequence = 0;
|
||||
/** @type {Map<number, {resolve: (value: unknown) => void, reject: (reason?: unknown) => void, timer: import('core').Timeout}>} */
|
||||
this._invocations = new Map();
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {number} */
|
||||
this._timeout = 5000;
|
||||
/** @type {number} */
|
||||
this._version = 1;
|
||||
/** @type {?number} */
|
||||
this._remoteVersion = null;
|
||||
/** @type {boolean} */
|
||||
this._enabled = false;
|
||||
/** @type {?Promise<void>} */
|
||||
this._setupPortPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the component is enabled.
|
||||
* @returns {boolean} Whether or not the object is enabled.
|
||||
*/
|
||||
isEnabled() {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes whether or not the component connection is enabled.
|
||||
* @param {boolean} enabled A boolean indicating whether or not the component should be enabled.
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
this._enabled = !!enabled;
|
||||
if (!this._enabled && this._port !== null) {
|
||||
this._clearPort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the current port, but does not disable future connections.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this._port !== null) {
|
||||
this._clearPort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the connection to the native application is active.
|
||||
* @returns {boolean} `true` if the connection is active, `false` otherwise.
|
||||
*/
|
||||
isConnected() {
|
||||
return (this._port !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not any invocation is currently active.
|
||||
* @returns {boolean} `true` if an invocation is active, `false` otherwise.
|
||||
*/
|
||||
isActive() {
|
||||
return (this._invocations.size > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the local API version being used.
|
||||
* @returns {number} An integer representing the API version that Yomitan uses.
|
||||
*/
|
||||
getLocalVersion() {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the version of the MeCab component.
|
||||
* @returns {Promise<?number>} The version of the MeCab component, or `null` if the component was not found.
|
||||
*/
|
||||
async getVersion() {
|
||||
try {
|
||||
await this._setupPortWrapper();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
return this._remoteVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string of Japanese text into arrays of lines and terms.
|
||||
*
|
||||
* Return value format:
|
||||
* ```js
|
||||
* [
|
||||
* {
|
||||
* name: (string),
|
||||
* lines: [
|
||||
* {term: (string), reading: (string), source: (string)},
|
||||
* ...
|
||||
* ]
|
||||
* },
|
||||
* ...
|
||||
* ]
|
||||
* ```
|
||||
* @param {string} text The string to parse.
|
||||
* @returns {Promise<import('mecab').ParseResult[]>} A collection of parsing results of the text.
|
||||
*/
|
||||
async parseText(text) {
|
||||
await this._setupPortWrapper();
|
||||
const rawResults = await this._invoke('parse_text', {text});
|
||||
// Note: The format of rawResults is not validated
|
||||
return this._convertParseTextResults(/** @type {import('mecab').ParseResultRaw} */ (rawResults));
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {unknown} message
|
||||
*/
|
||||
_onMessage(message) {
|
||||
if (typeof message !== 'object' || message === null) { return; }
|
||||
|
||||
const {sequence, data} = /** @type {import('core').SerializableObject} */ (message);
|
||||
if (typeof sequence !== 'number') { return; }
|
||||
|
||||
const invocation = this._invocations.get(sequence);
|
||||
if (typeof invocation === 'undefined') { return; }
|
||||
|
||||
const {resolve, timer} = invocation;
|
||||
clearTimeout(timer);
|
||||
resolve(data);
|
||||
this._invocations.delete(sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDisconnect() {
|
||||
if (this._port === null) { return; }
|
||||
const e = chrome.runtime.lastError;
|
||||
const error = new Error(e ? e.message : 'MeCab disconnected');
|
||||
for (const {reject, timer} of this._invocations.values()) {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
this._clearPort();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} action
|
||||
* @param {import('core').SerializableObject} params
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
_invoke(action, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this._port === null) {
|
||||
reject(new Error('Port disconnected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const sequence = this._sequence++;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this._invocations.delete(sequence);
|
||||
reject(new Error(`MeCab invoke timed out after ${this._timeout}ms`));
|
||||
}, this._timeout);
|
||||
|
||||
this._invocations.set(sequence, {resolve, reject, timer});
|
||||
|
||||
this._port.postMessage({action, params, sequence});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('mecab').ParseResultRaw} rawResults
|
||||
* @returns {import('mecab').ParseResult[]}
|
||||
*/
|
||||
_convertParseTextResults(rawResults) {
|
||||
/** @type {import('mecab').ParseResult[]} */
|
||||
const results = [];
|
||||
for (const [name, rawLines] of Object.entries(rawResults)) {
|
||||
/** @type {import('mecab').ParseFragment[][]} */
|
||||
const lines = [];
|
||||
for (const rawLine of rawLines) {
|
||||
const line = [];
|
||||
for (let {expression: term, reading, source} of rawLine) {
|
||||
if (typeof term !== 'string') { term = ''; }
|
||||
if (typeof reading !== 'string') { reading = ''; }
|
||||
if (typeof source !== 'string') { source = ''; }
|
||||
line.push({term, reading, source});
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
results.push({name, lines});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _setupPortWrapper() {
|
||||
if (!this._enabled) {
|
||||
throw new Error('MeCab not enabled');
|
||||
}
|
||||
if (this._setupPortPromise === null) {
|
||||
this._setupPortPromise = this._setupPort();
|
||||
}
|
||||
try {
|
||||
await this._setupPortPromise;
|
||||
} catch (e) {
|
||||
throw toError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _setupPort() {
|
||||
const port = chrome.runtime.connectNative('yomitan_mecab');
|
||||
this._eventListeners.addListener(port.onMessage, this._onMessage.bind(this));
|
||||
this._eventListeners.addListener(port.onDisconnect, this._onDisconnect.bind(this));
|
||||
this._port = port;
|
||||
|
||||
try {
|
||||
const data = await this._invoke('get_version', {});
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
throw new Error('Invalid version');
|
||||
}
|
||||
const {version} = /** @type {import('core').SerializableObject} */ (data);
|
||||
if (typeof version !== 'number') {
|
||||
throw new Error('Invalid version');
|
||||
}
|
||||
this._remoteVersion = version;
|
||||
if (version !== this._version) {
|
||||
throw new Error(`Unsupported MeCab native messenger version ${version}. Yomitan supports version ${this._version}.`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (this._port === port) {
|
||||
this._clearPort();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
_clearPort() {
|
||||
if (this._port !== null) {
|
||||
this._port.disconnect();
|
||||
this._port = null;
|
||||
}
|
||||
this._invocations.clear();
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._sequence = 0;
|
||||
this._setupPortPromise = null;
|
||||
}
|
||||
}
|
||||
93
vendor/yomitan/js/comm/shared-worker-bridge.js
vendored
Normal file
93
vendor/yomitan/js/comm/shared-worker-bridge.js
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 {createApiMap, invokeApiMapHandler} from '../core/api-map.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
import {log} from '../core/log.js';
|
||||
|
||||
/**
|
||||
* This serves as a bridge between the application and the backend on Firefox
|
||||
* where we don't have service workers.
|
||||
*
|
||||
* It is designed to have extremely short lifetime on the application side,
|
||||
* as otherwise it will stay alive across extension updates (which only restart
|
||||
* the backend) which can lead to extremely difficult to debug situations where
|
||||
* the bridge is running an old version of the code.
|
||||
*
|
||||
* All it does is broker a handshake between the application and the backend,
|
||||
* where they establish a connection between each other with a MessageChannel.
|
||||
*
|
||||
* # On backend startup
|
||||
* backend
|
||||
* ↓↓<"registerBackendPort" via SharedWorker.port.postMessage>↓↓
|
||||
* bridge: store the port in state
|
||||
*
|
||||
* # On application startup
|
||||
* application: create a new MessageChannel, bind event listeners to one of the ports, and send the other port to the bridge
|
||||
* ↓↓<"connectToBackend1" via SharedWorker.port.postMessage>↓↓
|
||||
* bridge
|
||||
* ↓↓<"connectToBackend2" via MessageChannel.port.postMessage which is stored in state from backend startup phase>↓↓
|
||||
* backend: bind event listeners to the other port
|
||||
*/
|
||||
export class SharedWorkerBridge {
|
||||
constructor() {
|
||||
/** @type {MessagePort?} */
|
||||
this._backendPort = null;
|
||||
|
||||
/** @type {import('shared-worker').ApiMap} */
|
||||
this._apiMap = createApiMap([
|
||||
['registerBackendPort', this._onRegisterBackendPort.bind(this)],
|
||||
['connectToBackend1', this._onConnectToBackend1.bind(this)],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
prepare() {
|
||||
addEventListener('connect', (connectEvent) => {
|
||||
const interlocutorPort = (/** @type {MessageEvent} */ (connectEvent)).ports[0];
|
||||
interlocutorPort.addEventListener('message', (/** @type {MessageEvent<import('shared-worker').ApiMessageAny>} */ event) => {
|
||||
const {action, params} = event.data;
|
||||
return invokeApiMapHandler(this._apiMap, action, params, [interlocutorPort, event.ports], () => {});
|
||||
});
|
||||
interlocutorPort.addEventListener('messageerror', (/** @type {MessageEvent} */ event) => {
|
||||
const error = new ExtensionError('SharedWorkerBridge: Error receiving message from interlocutor port when establishing connection');
|
||||
error.data = event;
|
||||
log.error(error);
|
||||
});
|
||||
interlocutorPort.start();
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {import('shared-worker').ApiHandler<'registerBackendPort'>} */
|
||||
_onRegisterBackendPort(_params, interlocutorPort, _ports) {
|
||||
this._backendPort = interlocutorPort;
|
||||
}
|
||||
|
||||
/** @type {import('shared-worker').ApiHandler<'connectToBackend1'>} */
|
||||
_onConnectToBackend1(_params, _interlocutorPort, ports) {
|
||||
if (this._backendPort !== null) {
|
||||
this._backendPort.postMessage(void 0, [ports[0]]); // connectToBackend2
|
||||
} else {
|
||||
log.warn('SharedWorkerBridge: backend port is not registered; this can happen if one of the content scripts loads faster than the backend when extension is reloading');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = new SharedWorkerBridge();
|
||||
bridge.prepare();
|
||||
623
vendor/yomitan/js/comm/yomitan-api.js
vendored
Normal file
623
vendor/yomitan/js/comm/yomitan-api.js
vendored
Normal file
@@ -0,0 +1,623 @@
|
||||
/*
|
||||
* 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 {parseHTML} from '../../lib/linkedom.js';
|
||||
import {OffscreenProxy} from '../background/offscreen-proxy.js';
|
||||
import {RequestBuilder} from '../background/request-builder.js';
|
||||
import {invokeApiMapHandler} from '../core/api-map.js';
|
||||
import {EventListenerCollection} from '../core/event-listener-collection.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
import {parseJson, readResponseJson} from '../core/json.js';
|
||||
import {log} from '../core/log.js';
|
||||
import {toError} from '../core/to-error.js';
|
||||
import {createFuriganaHtml, createFuriganaPlain} from '../data/anki-note-builder.js';
|
||||
import {getDynamicTemplates} from '../data/anki-template-util.js';
|
||||
import {generateAnkiNoteMediaFileName} from '../data/anki-util.js';
|
||||
import {getLanguageSummaries} from '../language/languages.js';
|
||||
import {AudioDownloader} from '../media/audio-downloader.js';
|
||||
import {getFileExtensionFromAudioMediaType, getFileExtensionFromImageMediaType} from '../media/media-util.js';
|
||||
import {getDictionaryEntryMedia} from '../pages/settings/anki-deck-generator-controller.js';
|
||||
import {AnkiTemplateRenderer} from '../templates/anki-template-renderer.js';
|
||||
|
||||
/** */
|
||||
export class YomitanApi {
|
||||
/**
|
||||
* @param {import('api').ApiMap} apiMap
|
||||
* @param {OffscreenProxy?} offscreen
|
||||
*/
|
||||
constructor(apiMap, offscreen) {
|
||||
/** @type {?chrome.runtime.Port} */
|
||||
this._port = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {number} */
|
||||
this._timeout = 5000;
|
||||
/** @type {number} */
|
||||
this._version = 1;
|
||||
/** @type {?number} */
|
||||
this._remoteVersion = null;
|
||||
/** @type {boolean} */
|
||||
this._enabled = false;
|
||||
/** @type {?Promise<void>} */
|
||||
this._setupPortPromise = null;
|
||||
/** @type {import('api').ApiMap} */
|
||||
this._apiMap = apiMap;
|
||||
/** @type {RequestBuilder} */
|
||||
this._requestBuilder = new RequestBuilder();
|
||||
/** @type {AudioDownloader} */
|
||||
this._audioDownloader = new AudioDownloader(this._requestBuilder);
|
||||
/** @type {OffscreenProxy?} */
|
||||
this._offscreen = offscreen;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEnabled() {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
async setEnabled(enabled) {
|
||||
this._enabled = !!enabled;
|
||||
if (!this._enabled && this._port !== null) {
|
||||
this._clearPort();
|
||||
}
|
||||
if (this._enabled) {
|
||||
await this.startApiServer();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
disconnect() {
|
||||
if (this._port !== null) {
|
||||
this._clearPort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isConnected() {
|
||||
return (this._port !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getLocalVersion() {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Promise<?number>}
|
||||
*/
|
||||
async getRemoteVersion(url) {
|
||||
if (this._port === null) {
|
||||
await this.startApiServer();
|
||||
}
|
||||
await this._updateRemoteVersion(url);
|
||||
return this._remoteVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async startApiServer() {
|
||||
try {
|
||||
await this._setupPortWrapper();
|
||||
return true;
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {unknown} message
|
||||
*/
|
||||
async _onMessage(message) {
|
||||
if (typeof message !== 'object' || message === null) { return; }
|
||||
|
||||
if (this._port !== null) {
|
||||
const {action, params, body} = /** @type {import('core').SerializableObject} */ (message);
|
||||
if (typeof action !== 'string' || typeof params !== 'object' || typeof body !== 'string') {
|
||||
this._port.postMessage({action, params, body, data: 'null', responseStatusCode: 400});
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsFull = await this._invoke('optionsGetFull', void 0);
|
||||
|
||||
try {
|
||||
/** @type {?object} */
|
||||
const parsedBody = body.length > 0 ? parseJson(body) : {};
|
||||
if (parsedBody === null) {
|
||||
throw new Error('Invalid request body');
|
||||
}
|
||||
|
||||
let result = null;
|
||||
let statusCode = 200;
|
||||
switch (action) {
|
||||
case 'yomitanVersion': {
|
||||
const {version} = chrome.runtime.getManifest();
|
||||
result = {version: version};
|
||||
break;
|
||||
}
|
||||
case 'termEntries': {
|
||||
/** @type {import('yomitan-api.js').termEntriesInput} */
|
||||
// @ts-expect-error - Allow this to error
|
||||
const {term} = parsedBody;
|
||||
const invokeParams = {
|
||||
text: term,
|
||||
details: {},
|
||||
optionsContext: {index: optionsFull.profileCurrent},
|
||||
};
|
||||
result = await this._invoke(
|
||||
'termsFind',
|
||||
invokeParams,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'kanjiEntries': {
|
||||
/** @type {import('yomitan-api.js').kanjiEntriesInput} */
|
||||
// @ts-expect-error - Allow this to error
|
||||
const {character} = parsedBody;
|
||||
const invokeParams = {
|
||||
text: character,
|
||||
details: {},
|
||||
optionsContext: {index: optionsFull.profileCurrent},
|
||||
};
|
||||
result = await this._invoke(
|
||||
'kanjiFind',
|
||||
invokeParams,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'ankiFields': {
|
||||
/** @type {import('yomitan-api.js').ankiFieldsInput} */
|
||||
// @ts-expect-error - Allow this to error
|
||||
const {text, type, markers, maxEntries, includeMedia} = parsedBody;
|
||||
const includeAudioMedia = includeMedia && markers.includes('audio');
|
||||
|
||||
const profileOptions = optionsFull.profiles[optionsFull.profileCurrent].options;
|
||||
|
||||
const ankiTemplate = await this._getAnkiTemplate(profileOptions);
|
||||
let dictionaryEntries = await this._getDictionaryEntries(text, type, optionsFull.profileCurrent);
|
||||
if (maxEntries > 0) {
|
||||
dictionaryEntries = dictionaryEntries.slice(0, maxEntries);
|
||||
}
|
||||
|
||||
// @ts-expect-error - `parseHTML` can return `null` but this input has been validated to not be `null`
|
||||
const domlessDocument = parseHTML('').document;
|
||||
// @ts-expect-error - `parseHTML` can return `null` but this input has been validated to not be `null`
|
||||
const domlessWindow = parseHTML('').window;
|
||||
|
||||
const dictionaryMedia = includeMedia ? await this._fetchDictionaryMedia(dictionaryEntries) : [];
|
||||
const audioMedia = includeAudioMedia ? await this._fetchAudio(dictionaryEntries, profileOptions) : [];
|
||||
const commonDatas = await this._createCommonDatas(text, dictionaryEntries, dictionaryMedia, audioMedia, profileOptions, domlessDocument);
|
||||
const ankiTemplateRenderer = new AnkiTemplateRenderer(domlessDocument, domlessWindow);
|
||||
await ankiTemplateRenderer.prepare();
|
||||
const templateRenderer = ankiTemplateRenderer.templateRenderer;
|
||||
|
||||
/** @type {Array<Record<string, string>>} */
|
||||
const ankiFieldsResults = [];
|
||||
for (const commonData of commonDatas) {
|
||||
/** @type {Record<string, string>} */
|
||||
const ankiFieldsResult = {};
|
||||
for (const marker of markers) {
|
||||
const templateResult = templateRenderer.render(ankiTemplate, {marker: marker, commonData: commonData}, 'ankiNote');
|
||||
ankiFieldsResult[marker] = templateResult.result;
|
||||
}
|
||||
ankiFieldsResults.push(ankiFieldsResult);
|
||||
}
|
||||
result = {
|
||||
fields: ankiFieldsResults,
|
||||
dictionaryMedia: dictionaryMedia,
|
||||
audioMedia: audioMedia,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'tokenize': {
|
||||
/** @type {import('yomitan-api.js').tokenizeInput} */
|
||||
// @ts-expect-error - Allow this to error
|
||||
const {text, scanLength} = parsedBody;
|
||||
if (typeof text !== 'string' && !Array.isArray(text)) {
|
||||
throw new Error('Invalid input for tokenize, expected "text" to be a string or a string array but got ' + typeof text);
|
||||
}
|
||||
if (typeof scanLength !== 'number') {
|
||||
throw new Error('Invalid input for tokenize, expected "scanLength" to be a number but got ' + typeof scanLength);
|
||||
}
|
||||
const invokeParams = {
|
||||
text: text,
|
||||
optionsContext: {index: optionsFull.profileCurrent},
|
||||
scanLength: scanLength,
|
||||
useInternalParser: true,
|
||||
useMecabParser: false,
|
||||
};
|
||||
result = await this._invoke('parseText', invokeParams);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
this._port.postMessage({action, params, body, data: result, responseStatusCode: statusCode});
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
this._port.postMessage({action, params, body, data: JSON.stringify(error), responseStatusCode: 500});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _getAnkiTemplate(options) {
|
||||
let staticTemplates = options.anki.fieldTemplates;
|
||||
if (typeof staticTemplates !== 'string') { staticTemplates = await this._invoke('getDefaultAnkiFieldTemplates', void 0); }
|
||||
const dictionaryInfo = await this._invoke('getDictionaryInfo', void 0);
|
||||
const dynamicTemplates = getDynamicTemplates(options, dictionaryInfo);
|
||||
return staticTemplates + '\n' + dynamicTemplates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import('settings.js').AnkiCardFormatType} type
|
||||
* @param {number} profileIndex
|
||||
* @returns {Promise<import('dictionary.js').DictionaryEntry[]>}
|
||||
*/
|
||||
async _getDictionaryEntries(text, type, profileIndex) {
|
||||
if (type === 'term') {
|
||||
const invokeParams = {
|
||||
text: text,
|
||||
details: {},
|
||||
optionsContext: {index: profileIndex},
|
||||
};
|
||||
return (await this._invoke('termsFind', invokeParams)).dictionaryEntries;
|
||||
} else {
|
||||
const invokeParams = {
|
||||
text: text,
|
||||
details: {},
|
||||
optionsContext: {index: profileIndex},
|
||||
};
|
||||
return await this._invoke('kanjiFind', invokeParams);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary.js').DictionaryEntry[]} dictionaryEntries
|
||||
* @returns {Promise<import('yomitan-api.js').apiDictionaryMediaDetails[]>}
|
||||
*/
|
||||
async _fetchDictionaryMedia(dictionaryEntries) {
|
||||
/** @type {import('yomitan-api.js').apiDictionaryMediaDetails[]} */
|
||||
const media = [];
|
||||
let mediaCount = 0;
|
||||
for (const dictionaryEntry of dictionaryEntries) {
|
||||
const dictionaryEntryMedias = getDictionaryEntryMedia(dictionaryEntry);
|
||||
const mediaRequestTargets = dictionaryEntryMedias.map((x) => { return {path: x.path, dictionary: x.dictionary}; });
|
||||
const mediaFilesData = await this._invoke('getMedia', {
|
||||
targets: mediaRequestTargets,
|
||||
});
|
||||
for (const mediaFileData of mediaFilesData) {
|
||||
if (media.some((x) => x.dictionary === mediaFileData.dictionary && x.path === mediaFileData.path)) { continue; }
|
||||
const timestamp = Date.now();
|
||||
const ankiFilename = generateAnkiNoteMediaFileName(`yomitan_dictionary_media_${mediaCount}`, getFileExtensionFromImageMediaType(mediaFileData.mediaType) ?? '', timestamp);
|
||||
media.push({
|
||||
dictionary: mediaFileData.dictionary,
|
||||
path: mediaFileData.path,
|
||||
mediaType: mediaFileData.mediaType,
|
||||
width: mediaFileData.width,
|
||||
height: mediaFileData.height,
|
||||
content: mediaFileData.content,
|
||||
ankiFilename: ankiFilename,
|
||||
});
|
||||
mediaCount += 1;
|
||||
}
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('dictionary.js').DictionaryEntry[]} dictionaryEntries
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @returns {Promise<import('yomitan-api.js').apiAudioMediaDetails[]>}
|
||||
*/
|
||||
async _fetchAudio(dictionaryEntries, options) {
|
||||
const audioDatas = [];
|
||||
const idleTimeout = (Number.isFinite(options.anki.downloadTimeout) && options.anki.downloadTimeout > 0 ? options.anki.downloadTimeout : null);
|
||||
const languageSummary = getLanguageSummaries().find(({iso}) => iso === options.general.language);
|
||||
if (!languageSummary) { return []; }
|
||||
for (const dictionaryEntry of dictionaryEntries) {
|
||||
if (dictionaryEntry.type === 'kanji') { continue; }
|
||||
const headword = dictionaryEntry.headwords[0]; // Only one headword is accepted for Anki card creation
|
||||
try {
|
||||
const audioData = await this._audioDownloader.downloadTermAudio(options.audio.sources, null, headword.term, headword.reading, idleTimeout, languageSummary, options.audio.enableDefaultAudioSources);
|
||||
const timestamp = Date.now();
|
||||
const mediaType = audioData.contentType ?? '';
|
||||
let extension = mediaType !== null ? getFileExtensionFromAudioMediaType(mediaType) : null;
|
||||
if (extension === null) { extension = '.mp3'; }
|
||||
const ankiFilename = generateAnkiNoteMediaFileName('yomitan_audio', extension, timestamp);
|
||||
audioDatas.push({
|
||||
term: headword.term,
|
||||
reading: headword.reading,
|
||||
mediaType: mediaType,
|
||||
content: audioData.data,
|
||||
ankiFilename: ankiFilename,
|
||||
});
|
||||
} catch (e) {
|
||||
log.log('Yomitan API failed to download audio ' + toError(e).message);
|
||||
}
|
||||
}
|
||||
return audioDatas;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import('dictionary.js').DictionaryEntry[]} dictionaryEntries
|
||||
* @param {import('yomitan-api.js').apiDictionaryMediaDetails[]} dictionaryMediaDetails
|
||||
* @param {import('yomitan-api.js').apiAudioMediaDetails[]} audioMediaDetails
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @param {Document} domlessDocument
|
||||
* @returns {Promise<import('anki-note-builder.js').CommonData[]>}
|
||||
*/
|
||||
async _createCommonDatas(text, dictionaryEntries, dictionaryMediaDetails, audioMediaDetails, options, domlessDocument) {
|
||||
/** @type {import('anki-note-builder.js').CommonData[]} */
|
||||
const commonDatas = [];
|
||||
for (const dictionaryEntry of dictionaryEntries) {
|
||||
/** @type {import('anki-templates.js').DictionaryMedia} */
|
||||
const dictionaryMedia = {};
|
||||
const dictionaryEntryMedias = getDictionaryEntryMedia(dictionaryEntry);
|
||||
if (dictionaryMediaDetails.length > 0) {
|
||||
for (const dictionaryEntryMedia of dictionaryEntryMedias) {
|
||||
const mediaFile = dictionaryMediaDetails.find((x) => x.dictionary === dictionaryEntryMedia.dictionary && x.path === dictionaryEntryMedia.path);
|
||||
if (!mediaFile) {
|
||||
log.error('Failed to find media for commonDatas generation');
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(dictionaryMedia, dictionaryEntryMedia.dictionary)) {
|
||||
dictionaryMedia[dictionaryEntryMedia.dictionary] = {};
|
||||
}
|
||||
dictionaryMedia[dictionaryEntryMedia.dictionary][dictionaryEntryMedia.path] = {value: mediaFile.ankiFilename};
|
||||
}
|
||||
}
|
||||
|
||||
let audioMediaFile = '';
|
||||
/** @type {import('api').ParseTextLine[]} */
|
||||
let furiganaData = [];
|
||||
if (dictionaryEntry.type === 'term') {
|
||||
audioMediaFile = audioMediaDetails.find((x) => x.term === dictionaryEntry.headwords[0].term && x.reading === dictionaryEntry.headwords[0].reading)?.ankiFilename ?? '';
|
||||
|
||||
furiganaData = [[{
|
||||
text: dictionaryEntry.headwords[0].term,
|
||||
reading: dictionaryEntry.headwords[0].reading,
|
||||
}]];
|
||||
}
|
||||
const furiganaReadingMode = options.parsing.readingMode === 'hiragana' || options.parsing.readingMode === 'katakana' ? options.parsing.readingMode : null;
|
||||
|
||||
commonDatas.push({
|
||||
dictionaryEntry: dictionaryEntry,
|
||||
resultOutputMode: 'group',
|
||||
cardFormat: {
|
||||
type: 'term',
|
||||
name: '',
|
||||
deck: '',
|
||||
model: '',
|
||||
fields: {},
|
||||
icon: 'big-circle',
|
||||
},
|
||||
glossaryLayoutMode: 'default',
|
||||
compactTags: false,
|
||||
context: {
|
||||
url: '',
|
||||
documentTitle: '',
|
||||
query: text,
|
||||
fullQuery: text,
|
||||
sentence: {
|
||||
text: '',
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
media: {
|
||||
audio: audioMediaFile.length > 0 ? {value: audioMediaFile} : void 0,
|
||||
textFurigana: [{
|
||||
text: text,
|
||||
readingMode: furiganaReadingMode,
|
||||
detailsHtml: {
|
||||
value: createFuriganaHtml(furiganaData, furiganaReadingMode, null),
|
||||
},
|
||||
detailsPlain: {
|
||||
value: createFuriganaPlain(furiganaData, furiganaReadingMode, null),
|
||||
},
|
||||
}],
|
||||
dictionaryMedia: dictionaryMedia,
|
||||
},
|
||||
dictionaryStylesMap: await this._getDictionaryStylesMapDomless(options, domlessDocument),
|
||||
});
|
||||
}
|
||||
return commonDatas;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @param {Document} domlessDocument
|
||||
* @returns {Promise<Map<string, string>>}
|
||||
*/
|
||||
async _getDictionaryStylesMapDomless(options, domlessDocument) {
|
||||
const styleMap = new Map();
|
||||
for (const dictionary of options.dictionaries) {
|
||||
const {name, styles} = dictionary;
|
||||
if (typeof styles === 'string') {
|
||||
// newlines and returns do not get converted into json well, are not required in css, and cause invalid css if not parsed for by the api consumer, just do the work for them
|
||||
const sanitizedCSS = (await this._sanitizeCSSOffscreen(options, styles, domlessDocument)).replaceAll(/(\r|\n)/g, ' ');
|
||||
styleMap.set(name, sanitizedCSS);
|
||||
}
|
||||
}
|
||||
return styleMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @param {string} css
|
||||
* @param {Document} domlessDocument
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _sanitizeCSSOffscreen(options, css, domlessDocument) {
|
||||
if (css.length === 0) { return ''; }
|
||||
try {
|
||||
if (!this._offscreen) {
|
||||
throw new Error('Offscreen page not available');
|
||||
}
|
||||
const sanitizedCSS = this._offscreen ? await this._offscreen.sendMessagePromise({action: 'sanitizeCSSOffscreen', params: {css}}) : '';
|
||||
if (sanitizedCSS.length === 0 && css.length > 0) {
|
||||
throw new Error('CSS parsing failed');
|
||||
}
|
||||
return sanitizedCSS;
|
||||
} catch (e) {
|
||||
log.log('Offscreen CSS sanitizer failed: ' + toError(e).message);
|
||||
}
|
||||
|
||||
try {
|
||||
const style = domlessDocument.createElement('style');
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
style.innerHTML = css;
|
||||
domlessDocument.appendChild(style);
|
||||
const styleSheet = style.sheet;
|
||||
if (!styleSheet) {
|
||||
throw new Error('CSS parsing failed');
|
||||
}
|
||||
return [...styleSheet.cssRules].map((rule) => rule.cssText || '').join('\n');
|
||||
} catch (e) {
|
||||
log.log('CSSOM CSS sanitizer failed: ' + toError(e).message);
|
||||
}
|
||||
|
||||
if (options.general.yomitanApiAllowCssSanitizationBypass) {
|
||||
log.log('Failed to sanitize CSS. Sanitization bypass is enabled, passing through CSS without sanitization: ' + css.replaceAll(/(\r|\n)/g, ' '));
|
||||
return css;
|
||||
}
|
||||
|
||||
log.log('Failed to sanitize CSS: ' + css.replaceAll(/(\r|\n)/g, ' '));
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
async _updateRemoteVersion(url) {
|
||||
if (!url) {
|
||||
throw new Error('Missing Yomitan API URL');
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url + '/serverVersion', {
|
||||
method: 'POST',
|
||||
});
|
||||
/** @type {import('yomitan-api.js').remoteVersionResponse} */
|
||||
const {version} = await readResponseJson(response);
|
||||
|
||||
this._remoteVersion = version;
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
throw new Error('Failed to fetch. Try again in a moment. The nativemessaging component can take a few seconds to start.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDisconnect() {
|
||||
if (this._port === null) { return; }
|
||||
const e = chrome.runtime.lastError;
|
||||
const error = new Error(e ? e.message : 'Yomitan Api disconnected');
|
||||
log.error(error);
|
||||
this._clearPort();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _setupPortWrapper() {
|
||||
if (!this._enabled) {
|
||||
throw new Error('Yomitan Api not enabled');
|
||||
}
|
||||
if (this._setupPortPromise === null) {
|
||||
this._setupPortPromise = this._setupPort();
|
||||
}
|
||||
try {
|
||||
await this._setupPortPromise;
|
||||
} catch (e) {
|
||||
throw toError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _setupPort() {
|
||||
const port = chrome.runtime.connectNative('yomitan_api');
|
||||
this._eventListeners.addListener(port.onMessage, this._onMessage.bind(this));
|
||||
this._eventListeners.addListener(port.onDisconnect, this._onDisconnect.bind(this));
|
||||
this._port = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
_clearPort() {
|
||||
if (this._port !== null) {
|
||||
this._port.disconnect();
|
||||
this._port = null;
|
||||
}
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._setupPortPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('api').ApiNames} TAction
|
||||
* @template {import('api').ApiParams<TAction>} TParams
|
||||
* @param {TAction} action
|
||||
* @param {TParams} params
|
||||
* @returns {Promise<import('api').ApiReturn<TAction>>}
|
||||
*/
|
||||
_invoke(action, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
invokeApiMapHandler(this._apiMap, action, params, [{}], (response) => {
|
||||
if (response !== null && typeof response === 'object') {
|
||||
const {error} = /** @type {import('core').UnknownObject} */ (response);
|
||||
if (typeof error !== 'undefined') {
|
||||
reject(ExtensionError.deserialize(/** @type {import('core').SerializedError} */(error)));
|
||||
} else {
|
||||
const {result} = /** @type {import('core').UnknownObject} */ (response);
|
||||
resolve(/** @type {import('api').ApiReturn<TAction>} */(result));
|
||||
}
|
||||
} else {
|
||||
const message = response === null ? 'Unexpected null response. You may need to refresh the page.' : `Unexpected response of type ${typeof response}. You may need to refresh the page.`;
|
||||
reject(new Error(`${message} (${JSON.stringify(action)})`));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
94
vendor/yomitan/js/core/api-map.js
vendored
Normal file
94
vendor/yomitan/js/core/api-map.js
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 './extension-error.js';
|
||||
|
||||
/**
|
||||
* @template {import('api-map').ApiSurface} [TApiSurface=never]
|
||||
* @template {unknown[]} [TExtraParams=[]]
|
||||
* @param {import('api-map').ApiMapInit<TApiSurface, TExtraParams>} init
|
||||
* @returns {import('api-map').ApiMap<TApiSurface, TExtraParams>}
|
||||
*/
|
||||
export function createApiMap(init) {
|
||||
return new Map(init);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('api-map').ApiSurface} [TApiSurface=never]
|
||||
* @template {unknown[]} [TExtraParams=[]]
|
||||
* @param {import('api-map').ApiMap<TApiSurface, TExtraParams>} map
|
||||
* @param {import('api-map').ApiMapInit<TApiSurface, TExtraParams>} init
|
||||
* @throws {Error}
|
||||
*/
|
||||
export function extendApiMap(map, init) {
|
||||
for (const [key, value] of init) {
|
||||
if (map.has(key)) { throw new Error(`The handler for ${String(key)} has already been registered`); }
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('api-map').ApiSurface} [TApiSurface=never]
|
||||
* @template {unknown[]} [TExtraParams=[]]
|
||||
* @param {import('api-map').ApiMap<TApiSurface, TExtraParams>} map
|
||||
* @param {string} name
|
||||
* @returns {import('api-map').ApiHandlerAny<TApiSurface, TExtraParams>|undefined}
|
||||
*/
|
||||
export function getApiMapHandler(map, name) {
|
||||
return map.get(/** @type {import('api-map').ApiNames<TApiSurface>} */ (name));
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('api-map').ApiSurface} [TApiSurface=never]
|
||||
* @template {unknown[]} [TExtraParams=[]]
|
||||
* @param {import('api-map').ApiMap<TApiSurface, TExtraParams>} map
|
||||
* @param {string} name
|
||||
* @param {import('api-map').ApiParamsAny<TApiSurface>} params
|
||||
* @param {TExtraParams} extraParams
|
||||
* @param {(response: import('core').Response<import('api-map').ApiReturnAny<TApiSurface>>) => void} callback
|
||||
* @param {() => void} [handlerNotFoundCallback]
|
||||
* @returns {boolean} `true` if async, `false` otherwise.
|
||||
*/
|
||||
export function invokeApiMapHandler(map, name, params, extraParams, callback, handlerNotFoundCallback) {
|
||||
const handler = getApiMapHandler(map, name);
|
||||
if (typeof handler === 'undefined') {
|
||||
if (typeof handlerNotFoundCallback === 'function') {
|
||||
try {
|
||||
handlerNotFoundCallback();
|
||||
} catch (error) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const promiseOrResult = handler(/** @type {import('core').SafeAny} */ (params), ...extraParams);
|
||||
if (promiseOrResult instanceof Promise) {
|
||||
/** @type {Promise<unknown>} */ (promiseOrResult).then(
|
||||
(result) => { callback({result}); },
|
||||
(error) => { callback({error: ExtensionError.serialize(error)}); },
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
callback({result: promiseOrResult});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
callback({error: ExtensionError.serialize(error)});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
131
vendor/yomitan/js/core/dynamic-property.js
vendored
Normal file
131
vendor/yomitan/js/core/dynamic-property.js
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 './event-dispatcher.js';
|
||||
import {generateId} from './utilities.js';
|
||||
|
||||
/**
|
||||
* Class representing a generic value with an override stack.
|
||||
* Changes can be observed by listening to the 'change' event.
|
||||
* @template [T=unknown]
|
||||
* @augments EventDispatcher<import('dynamic-property').Events<T>>
|
||||
*/
|
||||
export class DynamicProperty extends EventDispatcher {
|
||||
/**
|
||||
* Creates a new instance with the specified value.
|
||||
* @param {T} value The value to assign.
|
||||
*/
|
||||
constructor(value) {
|
||||
super();
|
||||
/** @type {T} */
|
||||
this._value = value;
|
||||
/** @type {T} */
|
||||
this._defaultValue = value;
|
||||
/** @type {{value: T, priority: number, token: string}[]} */
|
||||
this._overrides = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default value for the property, which is assigned to the
|
||||
* public value property when no overrides are present.
|
||||
* @type {T}
|
||||
*/
|
||||
get defaultValue() {
|
||||
return this._defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the default value for the property. If no overrides are present
|
||||
* and if the value is different than the current default value,
|
||||
* the 'change' event will be triggered.
|
||||
* @param {T} value The value to assign.
|
||||
*/
|
||||
set defaultValue(value) {
|
||||
this._defaultValue = value;
|
||||
if (this._overrides.length === 0) { this._updateValue(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current value for the property, taking any overrides into account.
|
||||
* @type {T}
|
||||
*/
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of overrides added to the property.
|
||||
* @type {number}
|
||||
*/
|
||||
get overrideCount() {
|
||||
return this._overrides.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an override value with the specified priority to the override stack.
|
||||
* Values with higher priority will take precedence over those with lower.
|
||||
* For tie breaks, the override value added first will take precedence.
|
||||
* If the newly added override has the highest priority of all overrides
|
||||
* and if the override value is different from the current value,
|
||||
* the 'change' event will be fired.
|
||||
* @param {T} value The override value to assign.
|
||||
* @param {number} [priority] The priority value to use, as a number.
|
||||
* @returns {import('core').TokenString} A string token which can be passed to the clearOverride function
|
||||
* to remove the override.
|
||||
*/
|
||||
setOverride(value, priority = 0) {
|
||||
const overridesCount = this._overrides.length;
|
||||
let i = 0;
|
||||
for (; i < overridesCount; ++i) {
|
||||
if (priority > this._overrides[i].priority) { break; }
|
||||
}
|
||||
const token = generateId(16);
|
||||
this._overrides.splice(i, 0, {value, priority, token});
|
||||
if (i === 0) { this._updateValue(); }
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a specific override value. If the removed override
|
||||
* had the highest priority, and the new value is different from
|
||||
* the previous value, the 'change' event will be fired.
|
||||
* @param {import('core').TokenString} token The token for the corresponding override which is to be removed.
|
||||
* @returns {boolean} `true` if an override was returned, `false` otherwise.
|
||||
*/
|
||||
clearOverride(token) {
|
||||
for (let i = 0, ii = this._overrides.length; i < ii; ++i) {
|
||||
if (this._overrides[i].token === token) {
|
||||
this._overrides.splice(i, 1);
|
||||
if (i === 0) { this._updateValue(); }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current value using the current overrides and default value.
|
||||
* If the new value differs from the previous value, the 'change' event will be fired.
|
||||
*/
|
||||
_updateValue() {
|
||||
const value = this._overrides.length > 0 ? this._overrides[0].value : this._defaultValue;
|
||||
if (this._value === value) { return; }
|
||||
this._value = value;
|
||||
this.trigger('change', {value});
|
||||
}
|
||||
}
|
||||
105
vendor/yomitan/js/core/event-dispatcher.js
vendored
Normal file
105
vendor/yomitan/js/core/event-dispatcher.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The following typedef is required because the JSDoc `implements` tag doesn't work with `import()`.
|
||||
* https://github.com/microsoft/TypeScript/issues/49905
|
||||
* @typedef {import('core').EventDispatcherOffGeneric} EventDispatcherOffGeneric
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class controls basic event dispatching.
|
||||
* @template {import('core').EventSurface} TSurface
|
||||
* @implements {EventDispatcherOffGeneric}
|
||||
*/
|
||||
export class EventDispatcher {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {Map<import('core').EventNames<TSurface>, import('core').EventHandlerAny[]>} */
|
||||
this._eventMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an event with the given name and specified argument.
|
||||
* @template {import('core').EventNames<TSurface>} TName
|
||||
* @param {TName} eventName The string representing the event's name.
|
||||
* @param {import('core').EventArgument<TSurface, TName>} details The argument passed to the callback functions.
|
||||
* @returns {boolean} `true` if any callbacks were registered, `false` otherwise.
|
||||
*/
|
||||
trigger(eventName, details) {
|
||||
const callbacks = this._eventMap.get(eventName);
|
||||
if (typeof callbacks === 'undefined') { return false; }
|
||||
|
||||
for (const callback of callbacks) {
|
||||
callback(details);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single event listener to a specific event.
|
||||
* @template {import('core').EventNames<TSurface>} TName
|
||||
* @param {TName} eventName The string representing the event's name.
|
||||
* @param {import('core').EventHandler<TSurface, TName>} callback The event listener callback to add.
|
||||
*/
|
||||
on(eventName, callback) {
|
||||
let callbacks = this._eventMap.get(eventName);
|
||||
if (typeof callbacks === 'undefined') {
|
||||
callbacks = [];
|
||||
this._eventMap.set(eventName, callbacks);
|
||||
}
|
||||
callbacks.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single event listener from a specific event.
|
||||
* @template {import('core').EventNames<TSurface>} TName
|
||||
* @param {TName} eventName The string representing the event's name.
|
||||
* @param {import('core').EventHandler<TSurface, TName>} callback The event listener callback to add.
|
||||
* @returns {boolean} `true` if the callback was removed, `false` otherwise.
|
||||
*/
|
||||
off(eventName, callback) {
|
||||
const callbacks = this._eventMap.get(eventName);
|
||||
if (typeof callbacks === 'undefined') { return false; }
|
||||
|
||||
const ii = callbacks.length;
|
||||
for (let i = 0; i < ii; ++i) {
|
||||
if (callbacks[i] === callback) {
|
||||
callbacks.splice(i, 1);
|
||||
if (callbacks.length === 0) {
|
||||
this._eventMap.delete(eventName);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an event has any listeners.
|
||||
* @template {import('core').EventNames<TSurface>} TName
|
||||
* @param {TName} eventName The string representing the event's name.
|
||||
* @returns {boolean} `true` if the event has listeners, `false` otherwise.
|
||||
*/
|
||||
hasListeners(eventName) {
|
||||
const callbacks = this._eventMap.get(eventName);
|
||||
return (typeof callbacks !== 'undefined' && callbacks.length > 0);
|
||||
}
|
||||
}
|
||||
97
vendor/yomitan/js/core/event-listener-collection.js
vendored
Normal file
97
vendor/yomitan/js/core/event-listener-collection.js
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class which stores event listeners added to various objects, making it easy to remove them in bulk.
|
||||
*/
|
||||
export class EventListenerCollection {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {import('event-listener-collection').EventListenerDetails[]} */
|
||||
this._eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of event listeners that are currently in the object.
|
||||
* @type {number}
|
||||
*/
|
||||
get size() {
|
||||
return this._eventListeners.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener using `object.addEventListener`. The listener will later be removed using `object.removeEventListener`.
|
||||
* @param {import('event-listener-collection').EventTarget} target The object to add the event listener to.
|
||||
* @param {string} type The name of the event.
|
||||
* @param {EventListener | EventListenerObject | import('event-listener-collection').EventListenerFunction} listener The callback listener.
|
||||
* @param {AddEventListenerOptions | boolean} [options] Options for the event.
|
||||
*/
|
||||
addEventListener(target, type, listener, options) {
|
||||
target.addEventListener(type, listener, options);
|
||||
this._eventListeners.push({type: 'removeEventListener', target, eventName: type, listener, options});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener using `object.addListener`. The listener will later be removed using `object.removeListener`.
|
||||
* @template {import('event-listener-collection').EventListenerFunction} TCallback
|
||||
* @template [TArgs=unknown]
|
||||
* @param {import('event-listener-collection').ExtensionEvent<TCallback, TArgs>} target The object to add the event listener to.
|
||||
* @param {TCallback} callback The callback.
|
||||
* @param {TArgs[]} args The extra argument array passed to the `addListener`/`removeListener` function.
|
||||
*/
|
||||
addListener(target, callback, ...args) {
|
||||
target.addListener(callback, ...args);
|
||||
this._eventListeners.push({type: 'removeListener', target, callback, args});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener using `object.on`. The listener will later be removed using `object.off`.
|
||||
* @template {import('core').EventSurface} TSurface
|
||||
* @template {import('core').EventNames<TSurface>} TName
|
||||
* @param {import('./event-dispatcher.js').EventDispatcher<TSurface>} target The object to add the event listener to.
|
||||
* @param {TName} eventName The string representing the event's name.
|
||||
* @param {import('core').EventHandler<TSurface, TName>} callback The event listener callback to add.
|
||||
*/
|
||||
on(target, eventName, callback) {
|
||||
target.on(eventName, callback);
|
||||
this._eventListeners.push({type: 'off', eventName, target, callback});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all event listeners added to objects for this instance and clears the internal list of event listeners.
|
||||
*/
|
||||
removeAllEventListeners() {
|
||||
if (this._eventListeners.length === 0) { return; }
|
||||
for (const item of this._eventListeners) {
|
||||
switch (item.type) {
|
||||
case 'removeEventListener':
|
||||
item.target.removeEventListener(item.eventName, item.listener, item.options);
|
||||
break;
|
||||
case 'removeListener':
|
||||
item.target.removeListener(item.callback, ...item.args);
|
||||
break;
|
||||
case 'off':
|
||||
item.target.off(item.eventName, item.callback);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._eventListeners = [];
|
||||
}
|
||||
}
|
||||
87
vendor/yomitan/js/core/extension-error.js
vendored
Normal file
87
vendor/yomitan/js/core/extension-error.js
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Custom error class for the extension which can contain extra data.
|
||||
* This works around an issue where assigning the `DOMException.data` field can fail on Firefox.
|
||||
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=1776555
|
||||
*/
|
||||
export class ExtensionError extends Error {
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
constructor(message) {
|
||||
super(message);
|
||||
/** @type {string} */
|
||||
this.name = 'ExtensionError';
|
||||
/** @type {unknown} */
|
||||
this._data = void 0;
|
||||
}
|
||||
|
||||
/** @type {unknown} */
|
||||
get data() { return this._data; }
|
||||
set data(value) { this._data = value; }
|
||||
|
||||
/**
|
||||
* Converts an `Error` object to a serializable JSON object.
|
||||
* @param {unknown} error An error object to convert.
|
||||
* @returns {import('core').SerializedError} A simple object which can be serialized by `JSON.stringify()`.
|
||||
*/
|
||||
static serialize(error) {
|
||||
try {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const {name, message, stack} = /** @type {import('core').SerializableObject} */ (error);
|
||||
/** @type {import('core').SerializedError1} */
|
||||
const result = {
|
||||
name: typeof name === 'string' ? name : '',
|
||||
message: typeof message === 'string' ? message : '',
|
||||
stack: typeof stack === 'string' ? stack : '',
|
||||
};
|
||||
if (error instanceof ExtensionError) {
|
||||
result.data = error.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
return /** @type {import('core').SerializedError2} */ ({
|
||||
value: error,
|
||||
hasValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a serialized error into a standard `Error` object.
|
||||
* @param {import('core').SerializedError} serializedError A simple object which was initially generated by the `serialize` function.
|
||||
* @returns {ExtensionError} A new `Error` instance.
|
||||
*/
|
||||
static deserialize(serializedError) {
|
||||
if (serializedError.hasValue) {
|
||||
const {value} = serializedError;
|
||||
return new ExtensionError(`Error of type ${typeof value}: ${value}`);
|
||||
}
|
||||
const {message, name, stack, data} = serializedError;
|
||||
const error = new ExtensionError(message);
|
||||
error.name = name;
|
||||
error.stack = stack;
|
||||
if (typeof data !== 'undefined') {
|
||||
error.data = data;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
}
|
||||
57
vendor/yomitan/js/core/fetch-utilities.js
vendored
Normal file
57
vendor/yomitan/js/core/fetch-utilities.js
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 {readResponseJson} from './json.js';
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function fetchAsset(url) {
|
||||
const response = await fetch(chrome.runtime.getURL(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}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function fetchText(url) {
|
||||
const response = await fetchAsset(url);
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {string} url
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export async function fetchJson(url) {
|
||||
const response = await fetchAsset(url);
|
||||
return await readResponseJson(response);
|
||||
}
|
||||
42
vendor/yomitan/js/core/json.js
vendored
Normal file
42
vendor/yomitan/js/core/json.js
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function is used to ensure more safe usage of `JSON.parse`.
|
||||
* By default, `JSON.parse` returns a value with type `any`, which is easy to misuse.
|
||||
* By changing the default to `unknown` and allowing it to be templatized,
|
||||
* this improves how the return type is used.
|
||||
* @template [T=unknown]
|
||||
* @param {string} value
|
||||
* @returns {T}
|
||||
*/
|
||||
export function parseJson(value) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to ensure more safe usage of `Response.json`,
|
||||
* which returns the `any` type.
|
||||
* @template [T=unknown]
|
||||
* @param {Response} response
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export async function readResponseJson(response) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return await response.json();
|
||||
}
|
||||
28
vendor/yomitan/js/core/log-utilities.js
vendored
Normal file
28
vendor/yomitan/js/core/log-utilities.js
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {import('log').LogLevel} errorLevel
|
||||
* @returns {number}
|
||||
*/
|
||||
export function logErrorLevelToNumber(errorLevel) {
|
||||
switch (errorLevel) {
|
||||
case 'warn': return 1;
|
||||
case 'error': return 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
149
vendor/yomitan/js/core/log.js
vendored
Normal file
149
vendor/yomitan/js/core/log.js
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 './event-dispatcher.js';
|
||||
import {ExtensionError} from './extension-error.js';
|
||||
|
||||
/**
|
||||
* This class handles logging of messages to the console and triggering an event for log calls.
|
||||
* @augments EventDispatcher<import('log').Events>
|
||||
*/
|
||||
class Logger extends EventDispatcher {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {string} */
|
||||
this._extensionName = 'Extension';
|
||||
/** @type {?string} */
|
||||
this._issueUrl = 'https://github.com/yomidevs/yomitan/issues';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} extensionName
|
||||
*/
|
||||
configure(extensionName) {
|
||||
this._extensionName = extensionName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} message
|
||||
* @param {...unknown} optionalParams
|
||||
*/
|
||||
log(message, ...optionalParams) {
|
||||
/* eslint-disable no-console */
|
||||
console.log(message, ...optionalParams);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning.
|
||||
* @param {unknown} error The error to log. This is typically an `Error` or `Error`-like object.
|
||||
*/
|
||||
warn(error) {
|
||||
this.logGenericError(error, 'warn');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error.
|
||||
* @param {unknown} error The error to log. This is typically an `Error` or `Error`-like object.
|
||||
*/
|
||||
error(error) {
|
||||
this.logGenericError(error, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a generic error.
|
||||
* @param {unknown} error The error to log. This is typically an `Error` or `Error`-like object.
|
||||
* @param {import('log').LogLevel} level
|
||||
* @param {import('log').LogContext} [context]
|
||||
*/
|
||||
logGenericError(error, level, context) {
|
||||
if (typeof context === 'undefined') {
|
||||
context = typeof location === 'undefined' ? {url: 'unknown'} : {url: location.href};
|
||||
}
|
||||
|
||||
let errorString;
|
||||
try {
|
||||
if (typeof error === 'string') {
|
||||
errorString = error;
|
||||
} else {
|
||||
errorString = (
|
||||
typeof error === 'object' && error !== null ?
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
error.toString() :
|
||||
`${error}`
|
||||
);
|
||||
if (/^\[object \w+\]$/.test(errorString)) {
|
||||
errorString = JSON.stringify(error);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errorString = `${error}`;
|
||||
}
|
||||
|
||||
let errorStack;
|
||||
try {
|
||||
errorStack = (
|
||||
error instanceof Error ?
|
||||
(typeof error.stack === 'string' ? error.stack.trimEnd() : '') :
|
||||
''
|
||||
);
|
||||
} catch (e) {
|
||||
errorStack = '';
|
||||
}
|
||||
|
||||
let errorData;
|
||||
try {
|
||||
if (error instanceof ExtensionError) {
|
||||
errorData = error.data;
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
if (errorStack.startsWith(errorString)) {
|
||||
errorString = errorStack;
|
||||
} else if (errorStack.length > 0) {
|
||||
errorString += `\n${errorStack}`;
|
||||
}
|
||||
|
||||
let message = `${this._extensionName} has encountered a problem.`;
|
||||
message += `\nOriginating URL: ${context.url}\n`;
|
||||
message += errorString;
|
||||
if (typeof errorData !== 'undefined') {
|
||||
message += `\nData: ${JSON.stringify(errorData, null, 4)}`;
|
||||
}
|
||||
if (this._issueUrl !== null) {
|
||||
message += `\n\nIssues can be reported at ${this._issueUrl}`;
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
switch (level) {
|
||||
case 'log': console.log(message); break;
|
||||
case 'warn': console.warn(message); break;
|
||||
case 'error': console.error(message); break;
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
|
||||
this.trigger('logGenericError', {error, level, context});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This object is the default logger used by the runtime.
|
||||
*/
|
||||
export const log = new Logger();
|
||||
32
vendor/yomitan/js/core/object-utilities.js
vendored
Normal file
32
vendor/yomitan/js/core/object-utilities.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is Record<string, unknown>}
|
||||
*/
|
||||
export function isObjectNotArray(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is Record<string|number|symbol, unknown>}
|
||||
*/
|
||||
export function isObject(value) {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
64
vendor/yomitan/js/core/promise-animation-frame.js
vendored
Normal file
64
vendor/yomitan/js/core/promise-animation-frame.js
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 {safePerformance} from './safe-performance.js';
|
||||
|
||||
/**
|
||||
* Creates a promise that will resolve after the next animation frame, using `requestAnimationFrame`.
|
||||
* @param {number} [timeout] A maximum duration (in milliseconds) to wait until the promise resolves. If null or omitted, no timeout is used.
|
||||
* @returns {Promise<{time: number, timeout: boolean}>} A promise that is resolved with `{time, timeout}`, where `time` is the timestamp from `requestAnimationFrame`,
|
||||
* and `timeout` is a boolean indicating whether the cause was a timeout or not.
|
||||
* @throws The promise throws an error if animation is not supported in this context, such as in a service worker.
|
||||
*/
|
||||
export function promiseAnimationFrame(timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof cancelAnimationFrame !== 'function' || typeof requestAnimationFrame !== 'function') {
|
||||
reject(new Error('Animation not supported in this context'));
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {?import('core').Timeout} */
|
||||
let timer = null;
|
||||
/** @type {?number} */
|
||||
let frameRequest = null;
|
||||
/**
|
||||
* @param {number} time
|
||||
*/
|
||||
const onFrame = (time) => {
|
||||
frameRequest = null;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
resolve({time, timeout: false});
|
||||
};
|
||||
const onTimeout = () => {
|
||||
timer = null;
|
||||
if (frameRequest !== null) {
|
||||
cancelAnimationFrame(frameRequest);
|
||||
frameRequest = null;
|
||||
}
|
||||
resolve({time: safePerformance.now(), timeout: true});
|
||||
};
|
||||
|
||||
frameRequest = requestAnimationFrame(onFrame);
|
||||
if (typeof timeout === 'number') {
|
||||
timer = setTimeout(onTimeout, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
68
vendor/yomitan/js/core/safe-performance.js
vendored
Normal file
68
vendor/yomitan/js/core/safe-performance.js
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 {log} from './log.js';
|
||||
|
||||
/**
|
||||
* This class safely handles performance methods.
|
||||
*/
|
||||
class SafePerformance {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* @param {string} markName
|
||||
* @param {PerformanceMarkOptions} [markOptions]
|
||||
* @returns {PerformanceMark | undefined}
|
||||
*/
|
||||
mark(markName, markOptions) {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return performance.mark(markName, markOptions);
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} measureName
|
||||
* @param {string | PerformanceMeasureOptions} [startOrMeasureOptions]
|
||||
* @param {string} [endMark]
|
||||
* @returns {PerformanceMeasure | undefined}
|
||||
*/
|
||||
measure(measureName, startOrMeasureOptions, endMark) {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return performance.measure(measureName, startOrMeasureOptions, endMark);
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {DOMHighResTimeStamp}
|
||||
*/
|
||||
now() {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This object is the default performance measurer used by the runtime.
|
||||
*/
|
||||
export const safePerformance = new SafePerformance();
|
||||
26
vendor/yomitan/js/core/to-error.js
vendored
Normal file
26
vendor/yomitan/js/core/to-error.js
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility function to convert an unknown value to an error.
|
||||
* This is useful for try-catch situations where the catch parameter has the `unknown` type.
|
||||
* @param {unknown} value
|
||||
* @returns {Error}
|
||||
*/
|
||||
export function toError(value) {
|
||||
return value instanceof Error ? value : new Error(`${value}`);
|
||||
}
|
||||
322
vendor/yomitan/js/core/utilities.js
vendored
Normal file
322
vendor/yomitan/js/core/utilities.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 {log} from './log.js';
|
||||
import {toError} from './to-error.js';
|
||||
|
||||
|
||||
/**
|
||||
* Converts any string into a form that can be passed into the RegExp constructor.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
|
||||
* @param {string} string The string to convert to a valid regular expression.
|
||||
* @returns {string} The escaped string.
|
||||
*/
|
||||
export function escapeRegExp(string) {
|
||||
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses a string.
|
||||
* @param {string} string The string to reverse.
|
||||
* @returns {string} The returned string, which retains proper UTF-16 surrogate pair order.
|
||||
*/
|
||||
export function stringReverse(string) {
|
||||
return [...string].reverse().join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deep clone of an object or value. This is similar to `parseJson(JSON.stringify(value))`.
|
||||
* @template [T=unknown]
|
||||
* @param {T} value The value to clone.
|
||||
* @returns {T} A new clone of the value.
|
||||
* @throws An error if the value is circular and cannot be cloned.
|
||||
*/
|
||||
export function clone(value) {
|
||||
if (value === null) { return /** @type {T} */ (null); }
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'string':
|
||||
case 'bigint':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return value;
|
||||
default:
|
||||
return cloneInternal(value, new Set());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {T} value
|
||||
* @param {Set<unknown>} visited
|
||||
* @returns {T}
|
||||
* @throws {Error}
|
||||
*/
|
||||
function cloneInternal(value, visited) {
|
||||
if (value === null) { return /** @type {T} */ (null); }
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'string':
|
||||
case 'bigint':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return value;
|
||||
case 'object':
|
||||
return /** @type {T} */ (
|
||||
Array.isArray(value) ?
|
||||
cloneArray(value, visited) :
|
||||
cloneObject(/** @type {import('core').SerializableObject} */ (value), visited)
|
||||
);
|
||||
default:
|
||||
throw new Error(`Cannot clone object of type ${typeof value}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[]} value
|
||||
* @param {Set<unknown>} visited
|
||||
* @returns {unknown[]}
|
||||
* @throws {Error}
|
||||
*/
|
||||
function cloneArray(value, visited) {
|
||||
if (visited.has(value)) { throw new Error('Circular'); }
|
||||
try {
|
||||
visited.add(value);
|
||||
const result = [];
|
||||
for (const item of value) {
|
||||
result.push(cloneInternal(item, visited));
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
visited.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('core').SerializableObject} value
|
||||
* @param {Set<unknown>} visited
|
||||
* @returns {import('core').SerializableObject}
|
||||
* @throws {Error}
|
||||
*/
|
||||
function cloneObject(value, visited) {
|
||||
if (visited.has(value)) { throw new Error('Circular'); }
|
||||
try {
|
||||
visited.add(value);
|
||||
/** @type {import('core').SerializableObject} */
|
||||
const result = {};
|
||||
for (const key in value) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
result[key] = cloneInternal(value[key], visited);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
visited.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an object or value is deeply equal to another object or value.
|
||||
* @param {unknown} value1 The first value to check.
|
||||
* @param {unknown} value2 The second value to check.
|
||||
* @returns {boolean} `true` if the values are the same object, or deeply equal without cycles. `false` otherwise.
|
||||
*/
|
||||
export function deepEqual(value1, value2) {
|
||||
if (value1 === value2) { return true; }
|
||||
|
||||
const type = typeof value1;
|
||||
if (typeof value2 !== type) { return false; }
|
||||
|
||||
switch (type) {
|
||||
case 'object':
|
||||
case 'function':
|
||||
return deepEqualInternal(value1, value2, new Set());
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value1
|
||||
* @param {unknown} value2
|
||||
* @param {Set<unknown>} visited1
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function deepEqualInternal(value1, value2, visited1) {
|
||||
if (value1 === value2) { return true; }
|
||||
|
||||
const type = typeof value1;
|
||||
if (typeof value2 !== type) { return false; }
|
||||
|
||||
switch (type) {
|
||||
case 'object':
|
||||
case 'function':
|
||||
{
|
||||
if (value1 === null || value2 === null) { return false; }
|
||||
const array = Array.isArray(value1);
|
||||
if (array !== Array.isArray(value2)) { return false; }
|
||||
if (visited1.has(value1)) { return false; }
|
||||
visited1.add(value1);
|
||||
return (
|
||||
array ?
|
||||
areArraysEqual(/** @type {unknown[]} */ (value1), /** @type {unknown[]} */ (value2), visited1) :
|
||||
areObjectsEqual(/** @type {import('core').UnknownObject} */ (value1), /** @type {import('core').UnknownObject} */ (value2), visited1)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('core').UnknownObject} value1
|
||||
* @param {import('core').UnknownObject} value2
|
||||
* @param {Set<unknown>} visited1
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function areObjectsEqual(value1, value2, visited1) {
|
||||
const keys1 = Object.keys(value1);
|
||||
const keys2 = Object.keys(value2);
|
||||
if (keys1.length !== keys2.length) { return false; }
|
||||
|
||||
const keys1Set = new Set(keys1);
|
||||
for (const key of keys2) {
|
||||
if (!keys1Set.has(key) || !deepEqualInternal(value1[key], value2[key], visited1)) { return false; }
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[]} value1
|
||||
* @param {unknown[]} value2
|
||||
* @param {Set<unknown>} visited1
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function areArraysEqual(value1, value2, visited1) {
|
||||
const length = value1.length;
|
||||
if (length !== value2.length) { return false; }
|
||||
|
||||
for (let i = 0; i < length; ++i) {
|
||||
if (!deepEqualInternal(value1[i], value2[i], visited1)) { return false; }
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new base-16 (lower case) string of a sequence of random bytes of the given length.
|
||||
* @param {number} length The number of bytes the string represents. The returned string's length will be twice as long.
|
||||
* @returns {string} A string of random characters.
|
||||
*/
|
||||
export function generateId(length) {
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
let id = '';
|
||||
for (const value of array) {
|
||||
id += value.toString(16).padStart(2, '0');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unresolved promise that can be resolved later, outside the promise's executor function.
|
||||
* @template [T=unknown]
|
||||
* @returns {import('core').DeferredPromiseDetails<T>} An object `{promise, resolve, reject}`, containing the promise and the resolve/reject functions.
|
||||
*/
|
||||
export function deferPromise() {
|
||||
/** @type {((value: T) => void)|undefined} */
|
||||
let resolve;
|
||||
/** @type {((reason?: import('core').RejectionReason) => void)|undefined} */
|
||||
let reject;
|
||||
/** @type {Promise<T>} */
|
||||
const promise = new Promise((resolve2, reject2) => {
|
||||
resolve = resolve2;
|
||||
reject = reject2;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: /** @type {(value: T) => void} */ (resolve),
|
||||
reject: /** @type {(reason?: import('core').RejectionReason) => void} */ (reject),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise that is resolved after a set delay.
|
||||
* @param {number} delay How many milliseconds until the promise should be resolved. If 0, the promise is immediately resolved.
|
||||
* @returns {Promise<void>} A promise with two additional properties: `resolve` and `reject`, which can be used to complete the promise early.
|
||||
*/
|
||||
export function promiseTimeout(delay) {
|
||||
return delay <= 0 ? Promise.resolve() : new Promise((resolve) => { setTimeout(resolve, delay); });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} css
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitizeCSS(css) {
|
||||
const sanitizer = new CSSStyleSheet();
|
||||
sanitizer.replaceSync(css);
|
||||
return [...sanitizer.cssRules].map((rule) => rule.cssText || '').join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} css
|
||||
* @param {string} scopeSelector
|
||||
* @returns {string}
|
||||
*/
|
||||
export function addScopeToCss(css, scopeSelector) {
|
||||
return scopeSelector + ' {' + css + '\n}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Older browser versions do not support nested css and cannot use the normal `addScopeToCss`.
|
||||
* All major web browsers should be fine but Anki is still distributing Chromium 112 on some platforms as of Anki version 24.11.
|
||||
* As of Anki 25.02, nesting is supported. However, many users take issue with changes around this time and refuse to update.
|
||||
* Chromium 120+ is required for full support.
|
||||
* @param {string} css
|
||||
* @param {string} scopeSelector
|
||||
* @returns {string}
|
||||
*/
|
||||
export function addScopeToCssLegacy(css, scopeSelector) {
|
||||
try {
|
||||
const stylesheet = new CSSStyleSheet();
|
||||
stylesheet.replaceSync(css);
|
||||
const newCSSRules = [];
|
||||
for (const cssRule of stylesheet.cssRules) {
|
||||
// ignore non-style rules
|
||||
if (!(cssRule instanceof CSSStyleRule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newSelectors = [];
|
||||
for (const selector of cssRule.selectorText.split(',')) {
|
||||
newSelectors.push(scopeSelector + ' ' + selector);
|
||||
}
|
||||
const newRule = cssRule.cssText.replace(cssRule.selectorText, newSelectors.join(', '));
|
||||
newCSSRules.push(newRule);
|
||||
}
|
||||
stylesheet.replaceSync(newCSSRules.join('\n'));
|
||||
return [...stylesheet.cssRules].map((rule) => rule.cssText || '').join('\n');
|
||||
} catch (e) {
|
||||
log.log('addScopeToCssLegacy failed, falling back on addScopeToCss: ' + toError(e).message);
|
||||
return addScopeToCss(css, scopeSelector);
|
||||
}
|
||||
}
|
||||
605
vendor/yomitan/js/data/anki-note-builder.js
vendored
Normal file
605
vendor/yomitan/js/data/anki-note-builder.js
vendored
Normal file
@@ -0,0 +1,605 @@
|
||||
/*
|
||||
* 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 {deferPromise, sanitizeCSS} from '../core/utilities.js';
|
||||
import {convertHiraganaToKatakana, convertKatakanaToHiragana} from '../language/ja/japanese.js';
|
||||
import {cloneFieldMarkerPattern, getRootDeckName} from './anki-util.js';
|
||||
|
||||
export class AnkiNoteBuilder {
|
||||
/**
|
||||
* Initiate an instance of AnkiNoteBuilder.
|
||||
* @param {import('anki-note-builder').MinimalApi} api
|
||||
* @param {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} templateRenderer
|
||||
*/
|
||||
constructor(api, templateRenderer) {
|
||||
/** @type {import('anki-note-builder').MinimalApi} */
|
||||
this._api = api;
|
||||
/** @type {RegExp} */
|
||||
this._markerPattern = cloneFieldMarkerPattern(true);
|
||||
/** @type {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} */
|
||||
this._templateRenderer = templateRenderer;
|
||||
/** @type {import('anki-note-builder').BatchedRequestGroup[]} */
|
||||
this._batchedRequests = [];
|
||||
/** @type {boolean} */
|
||||
this._batchedRequestsQueued = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki-note-builder').CreateNoteDetails} details
|
||||
* @returns {Promise<import('anki-note-builder').CreateNoteResult>}
|
||||
*/
|
||||
async createNote({
|
||||
dictionaryEntry,
|
||||
cardFormat,
|
||||
context,
|
||||
template,
|
||||
tags = [],
|
||||
requirements = [],
|
||||
duplicateScope = 'collection',
|
||||
duplicateScopeCheckAllModels = false,
|
||||
resultOutputMode = 'split',
|
||||
glossaryLayoutMode = 'default',
|
||||
compactTags = false,
|
||||
mediaOptions = null,
|
||||
dictionaryStylesMap = new Map(),
|
||||
}) {
|
||||
const {deck: deckName, model: modelName, fields: fieldsSettings} = cardFormat;
|
||||
const fields = Object.entries(fieldsSettings);
|
||||
let duplicateScopeDeckName = null;
|
||||
let duplicateScopeCheckChildren = false;
|
||||
if (duplicateScope === 'deck-root') {
|
||||
duplicateScope = 'deck';
|
||||
duplicateScopeDeckName = getRootDeckName(deckName);
|
||||
duplicateScopeCheckChildren = true;
|
||||
}
|
||||
|
||||
/** @type {Error[]} */
|
||||
const allErrors = [];
|
||||
let media;
|
||||
if (requirements.length > 0 && mediaOptions !== null) {
|
||||
let errors;
|
||||
({media, errors} = await this._injectMedia(dictionaryEntry, requirements, mediaOptions));
|
||||
for (const error of errors) {
|
||||
allErrors.push(ExtensionError.deserialize(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Make URL field blank if URL source is Yomitan
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
if (url.protocol === new URL(import.meta.url).protocol) {
|
||||
context.url = '';
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const commonData = this._createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, media, dictionaryStylesMap);
|
||||
const formattedFieldValuePromises = [];
|
||||
for (const [, {value: fieldValue}] of fields) {
|
||||
const formattedFieldValuePromise = this._formatField(fieldValue, commonData, template);
|
||||
formattedFieldValuePromises.push(formattedFieldValuePromise);
|
||||
}
|
||||
|
||||
const formattedFieldValues = await Promise.all(formattedFieldValuePromises);
|
||||
/** @type {Map<string, import('anki-note-builder').Requirement>} */
|
||||
const uniqueRequirements = new Map();
|
||||
/** @type {import('anki').NoteFields} */
|
||||
const noteFields = {};
|
||||
for (let i = 0, ii = fields.length; i < ii; ++i) {
|
||||
const fieldName = fields[i][0];
|
||||
const {value, errors: fieldErrors, requirements: fieldRequirements} = formattedFieldValues[i];
|
||||
noteFields[fieldName] = value;
|
||||
allErrors.push(...fieldErrors);
|
||||
for (const requirement of fieldRequirements) {
|
||||
const key = JSON.stringify(requirement);
|
||||
if (uniqueRequirements.has(key)) { continue; }
|
||||
uniqueRequirements.set(key, requirement);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('anki').Note} */
|
||||
const note = {
|
||||
fields: noteFields,
|
||||
tags,
|
||||
deckName,
|
||||
modelName,
|
||||
options: {
|
||||
allowDuplicate: true,
|
||||
duplicateScope,
|
||||
duplicateScopeOptions: {
|
||||
deckName: duplicateScopeDeckName,
|
||||
checkChildren: duplicateScopeCheckChildren,
|
||||
checkAllModels: duplicateScopeCheckAllModels,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {note, errors: allErrors, requirements: [...uniqueRequirements.values()]};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki-note-builder').GetRenderingDataDetails} details
|
||||
* @returns {Promise<import('anki-templates').NoteData>}
|
||||
*/
|
||||
async getRenderingData({
|
||||
dictionaryEntry,
|
||||
cardFormat,
|
||||
context,
|
||||
resultOutputMode = 'split',
|
||||
glossaryLayoutMode = 'default',
|
||||
compactTags = false,
|
||||
marker,
|
||||
dictionaryStylesMap,
|
||||
}) {
|
||||
const commonData = this._createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, void 0, dictionaryStylesMap);
|
||||
return await this._templateRenderer.getModifiedData({marker, commonData}, 'ankiNote');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
|
||||
* @returns {import('api').InjectAnkiNoteMediaDefinitionDetails}
|
||||
*/
|
||||
getDictionaryEntryDetailsForNote(dictionaryEntry) {
|
||||
const {type} = dictionaryEntry;
|
||||
if (type === 'kanji') {
|
||||
const {character} = dictionaryEntry;
|
||||
return {type, character};
|
||||
}
|
||||
|
||||
const {headwords} = dictionaryEntry;
|
||||
let bestIndex = -1;
|
||||
for (let i = 0, ii = headwords.length; i < ii; ++i) {
|
||||
const {term, reading, sources} = headwords[i];
|
||||
for (const {deinflectedText} of sources) {
|
||||
if (term === deinflectedText) {
|
||||
bestIndex = i;
|
||||
i = ii;
|
||||
break;
|
||||
} else if (reading === deinflectedText && bestIndex < 0) {
|
||||
bestIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {term, reading} = headwords[Math.max(0, bestIndex)];
|
||||
return {type, term, reading};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').DictionariesOptions} dictionaries
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
getDictionaryStylesMap(dictionaries) {
|
||||
const styleMap = new Map();
|
||||
for (const dictionary of dictionaries) {
|
||||
const {name, styles} = dictionary;
|
||||
if (typeof styles === 'string') {
|
||||
styleMap.set(name, sanitizeCSS(styles));
|
||||
}
|
||||
}
|
||||
return styleMap;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
|
||||
* @param {import('settings').AnkiCardFormat} cardFormat
|
||||
* @param {import('anki-templates-internal').Context} context
|
||||
* @param {import('settings').ResultOutputMode} resultOutputMode
|
||||
* @param {import('settings').GlossaryLayoutMode} glossaryLayoutMode
|
||||
* @param {boolean} compactTags
|
||||
* @param {import('anki-templates').Media|undefined} media
|
||||
* @param {Map<string, string>} dictionaryStylesMap
|
||||
* @returns {import('anki-note-builder').CommonData}
|
||||
*/
|
||||
_createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, media, dictionaryStylesMap) {
|
||||
return {
|
||||
dictionaryEntry,
|
||||
cardFormat,
|
||||
context,
|
||||
resultOutputMode,
|
||||
glossaryLayoutMode,
|
||||
compactTags,
|
||||
media,
|
||||
dictionaryStylesMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} field
|
||||
* @param {import('anki-note-builder').CommonData} commonData
|
||||
* @param {string} template
|
||||
* @returns {Promise<{value: string, errors: ExtensionError[], requirements: import('anki-note-builder').Requirement[]}>}
|
||||
*/
|
||||
async _formatField(field, commonData, template) {
|
||||
/** @type {ExtensionError[]} */
|
||||
const errors = [];
|
||||
/** @type {import('anki-note-builder').Requirement[]} */
|
||||
const requirements = [];
|
||||
const value = await this._stringReplaceAsync(field, this._markerPattern, async (match) => {
|
||||
const marker = match[1];
|
||||
try {
|
||||
const {result, requirements: fieldRequirements} = await this._renderTemplateBatched(template, commonData, marker);
|
||||
requirements.push(...fieldRequirements);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const error = new ExtensionError(`Template render error for {${marker}}`);
|
||||
error.data = {error: e};
|
||||
errors.push(error);
|
||||
return `{${marker}-render-error}`;
|
||||
}
|
||||
});
|
||||
return {value, errors, requirements};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @param {RegExp} regex
|
||||
* @param {(match: RegExpExecArray, index: number, str: string) => (string|Promise<string>)} replacer
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _stringReplaceAsync(str, regex, replacer) {
|
||||
let match;
|
||||
let index = 0;
|
||||
/** @type {(Promise<string>|string)[]} */
|
||||
const parts = [];
|
||||
while ((match = regex.exec(str)) !== null) {
|
||||
parts.push(str.substring(index, match.index), replacer(match, match.index, str));
|
||||
index = regex.lastIndex;
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return str;
|
||||
}
|
||||
parts.push(str.substring(index));
|
||||
return (await Promise.all(parts)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
* @returns {import('anki-note-builder').BatchedRequestGroup}
|
||||
*/
|
||||
_getBatchedTemplateGroup(template) {
|
||||
for (const item of this._batchedRequests) {
|
||||
if (item.template === template) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {template, commonDataRequestsMap: new Map()};
|
||||
this._batchedRequests.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
* @param {import('anki-note-builder').CommonData} commonData
|
||||
* @param {string} marker
|
||||
* @returns {Promise<import('template-renderer').RenderResult>}
|
||||
*/
|
||||
_renderTemplateBatched(template, commonData, marker) {
|
||||
/** @type {import('core').DeferredPromiseDetails<import('template-renderer').RenderResult>} */
|
||||
const {promise, resolve, reject} = deferPromise();
|
||||
const {commonDataRequestsMap} = this._getBatchedTemplateGroup(template);
|
||||
let requests = commonDataRequestsMap.get(commonData);
|
||||
if (typeof requests === 'undefined') {
|
||||
requests = [];
|
||||
commonDataRequestsMap.set(commonData, requests);
|
||||
}
|
||||
requests.push({resolve, reject, marker});
|
||||
this._runBatchedRequestsDelayed();
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
_runBatchedRequestsDelayed() {
|
||||
if (this._batchedRequestsQueued) { return; }
|
||||
this._batchedRequestsQueued = true;
|
||||
void Promise.resolve().then(() => {
|
||||
this._batchedRequestsQueued = false;
|
||||
this._runBatchedRequests();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
_runBatchedRequests() {
|
||||
if (this._batchedRequests.length === 0) { return; }
|
||||
|
||||
const allRequests = [];
|
||||
/** @type {import('template-renderer').RenderMultiItem[]} */
|
||||
const items = [];
|
||||
for (const {template, commonDataRequestsMap} of this._batchedRequests) {
|
||||
/** @type {import('template-renderer').RenderMultiTemplateItem[]} */
|
||||
const templateItems = [];
|
||||
for (const [commonData, requests] of commonDataRequestsMap.entries()) {
|
||||
/** @type {import('template-renderer').PartialOrCompositeRenderData[]} */
|
||||
const datas = [];
|
||||
for (const {marker} of requests) {
|
||||
datas.push({marker});
|
||||
}
|
||||
allRequests.push(...requests);
|
||||
templateItems.push({
|
||||
type: /** @type {import('anki-templates').RenderMode} */ ('ankiNote'),
|
||||
commonData,
|
||||
datas,
|
||||
});
|
||||
}
|
||||
items.push({template, templateItems});
|
||||
}
|
||||
|
||||
this._batchedRequests.length = 0;
|
||||
|
||||
void this._resolveBatchedRequests(items, allRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('template-renderer').RenderMultiItem[]} items
|
||||
* @param {import('anki-note-builder').BatchedRequestData[]} requests
|
||||
*/
|
||||
async _resolveBatchedRequests(items, requests) {
|
||||
let responses;
|
||||
try {
|
||||
responses = await this._templateRenderer.renderMulti(items);
|
||||
} catch (e) {
|
||||
for (const {reject} of requests) {
|
||||
reject(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0, ii = requests.length; i < ii; ++i) {
|
||||
const request = requests[i];
|
||||
try {
|
||||
const response = responses[i];
|
||||
const {error} = response;
|
||||
if (typeof error !== 'undefined') {
|
||||
throw ExtensionError.deserialize(error);
|
||||
} else {
|
||||
request.resolve(response.result);
|
||||
}
|
||||
} catch (e) {
|
||||
request.reject(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
|
||||
* @param {import('anki-note-builder').Requirement[]} requirements
|
||||
* @param {import('anki-note-builder').MediaOptions} mediaOptions
|
||||
* @returns {Promise<{media: import('anki-templates').Media, errors: import('core').SerializedError[]}>}
|
||||
*/
|
||||
async _injectMedia(dictionaryEntry, requirements, mediaOptions) {
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Parse requirements
|
||||
let injectAudio = false;
|
||||
let injectScreenshot = false;
|
||||
let injectClipboardImage = false;
|
||||
let injectClipboardText = false;
|
||||
let injectPopupSelectionText = false;
|
||||
/** @type {import('anki-note-builder').TextFuriganaDetails[]} */
|
||||
const textFuriganaDetails = [];
|
||||
/** @type {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} */
|
||||
const dictionaryMediaDetails = [];
|
||||
for (const requirement of requirements) {
|
||||
const {type} = requirement;
|
||||
switch (type) {
|
||||
case 'audio': injectAudio = true; break;
|
||||
case 'screenshot': injectScreenshot = true; break;
|
||||
case 'clipboardImage': injectClipboardImage = true; break;
|
||||
case 'clipboardText': injectClipboardText = true; break;
|
||||
case 'popupSelectionText': injectPopupSelectionText = true; break;
|
||||
case 'textFurigana':
|
||||
{
|
||||
const {text, readingMode} = requirement;
|
||||
textFuriganaDetails.push({text, readingMode});
|
||||
}
|
||||
break;
|
||||
case 'dictionaryMedia':
|
||||
{
|
||||
const {dictionary, path} = requirement;
|
||||
dictionaryMediaDetails.push({dictionary, path});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate request data
|
||||
const dictionaryEntryDetails = this.getDictionaryEntryDetailsForNote(dictionaryEntry);
|
||||
/** @type {?import('api').InjectAnkiNoteMediaAudioDetails} */
|
||||
let audioDetails = null;
|
||||
/** @type {?import('api').InjectAnkiNoteMediaScreenshotDetails} */
|
||||
let screenshotDetails = null;
|
||||
/** @type {import('api').InjectAnkiNoteMediaClipboardDetails} */
|
||||
const clipboardDetails = {image: injectClipboardImage, text: injectClipboardText};
|
||||
if (injectAudio && dictionaryEntryDetails.type !== 'kanji') {
|
||||
const audioOptions = mediaOptions.audio;
|
||||
if (typeof audioOptions === 'object' && audioOptions !== null) {
|
||||
const {sources, preferredAudioIndex, idleTimeout, languageSummary, enableDefaultAudioSources} = audioOptions;
|
||||
audioDetails = {sources, preferredAudioIndex, idleTimeout, languageSummary, enableDefaultAudioSources};
|
||||
}
|
||||
}
|
||||
if (injectScreenshot) {
|
||||
const screenshotOptions = mediaOptions.screenshot;
|
||||
if (typeof screenshotOptions === 'object' && screenshotOptions !== null) {
|
||||
const {format, quality, contentOrigin: {tabId, frameId}} = screenshotOptions;
|
||||
if (typeof tabId === 'number' && typeof frameId === 'number') {
|
||||
screenshotDetails = {tabId, frameId, format, quality};
|
||||
}
|
||||
}
|
||||
}
|
||||
let textFuriganaPromise = null;
|
||||
if (textFuriganaDetails.length > 0) {
|
||||
const textParsingOptions = mediaOptions.textParsing;
|
||||
if (typeof textParsingOptions === 'object' && textParsingOptions !== null) {
|
||||
const {optionsContext, scanLength} = textParsingOptions;
|
||||
textFuriganaPromise = this._getTextFurigana(textFuriganaDetails, optionsContext, scanLength, dictionaryEntryDetails);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject media
|
||||
const popupSelectionText = injectPopupSelectionText ? this._getPopupSelectionText() : null;
|
||||
const injectedMedia = await this._api.injectAnkiNoteMedia(
|
||||
timestamp,
|
||||
dictionaryEntryDetails,
|
||||
audioDetails,
|
||||
screenshotDetails,
|
||||
clipboardDetails,
|
||||
dictionaryMediaDetails,
|
||||
);
|
||||
const {audioFileName, screenshotFileName, clipboardImageFileName, clipboardText, dictionaryMedia: dictionaryMediaArray, errors} = injectedMedia;
|
||||
const textFurigana = textFuriganaPromise !== null ? await textFuriganaPromise : [];
|
||||
|
||||
// Format results
|
||||
/** @type {import('anki-templates').DictionaryMedia} */
|
||||
const dictionaryMedia = {};
|
||||
for (const {dictionary, path, fileName} of dictionaryMediaArray) {
|
||||
if (fileName === null) { continue; }
|
||||
const dictionaryMedia2 = (
|
||||
Object.prototype.hasOwnProperty.call(dictionaryMedia, dictionary) ?
|
||||
(dictionaryMedia[dictionary]) :
|
||||
(dictionaryMedia[dictionary] = {})
|
||||
);
|
||||
dictionaryMedia2[path] = {value: fileName};
|
||||
}
|
||||
const media = {
|
||||
audio: (typeof audioFileName === 'string' ? {value: audioFileName} : void 0),
|
||||
screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : void 0),
|
||||
clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : void 0),
|
||||
clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : void 0),
|
||||
popupSelectionText: (typeof popupSelectionText === 'string' ? {value: popupSelectionText} : void 0),
|
||||
textFurigana,
|
||||
dictionaryMedia,
|
||||
};
|
||||
return {media, errors};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
_getPopupSelectionText() {
|
||||
const selection = document.getSelection();
|
||||
return selection !== null ? selection.toString() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('anki-note-builder').TextFuriganaDetails[]} entries
|
||||
* @param {import('settings').OptionsContext} optionsContext
|
||||
* @param {number} scanLength
|
||||
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
|
||||
* @returns {Promise<import('anki-templates').TextFuriganaSegment[]>}
|
||||
*/
|
||||
async _getTextFurigana(entries, optionsContext, scanLength, readingOverride) {
|
||||
const results = [];
|
||||
for (const {text, readingMode} of entries) {
|
||||
const parseResults = await this._api.parseText(text, optionsContext, scanLength, true, false);
|
||||
let data = null;
|
||||
for (const {source, content} of parseResults) {
|
||||
if (source !== 'scanning-parser') { continue; }
|
||||
data = content;
|
||||
break;
|
||||
}
|
||||
if (data !== null) {
|
||||
const valueHtml = createFuriganaHtml(data, readingMode, readingOverride);
|
||||
const valuePlain = createFuriganaPlain(data, readingMode, readingOverride);
|
||||
results.push({text, readingMode, detailsHtml: {value: valueHtml}, detailsPlain: {value: valuePlain}});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ParseTextLine[]} data
|
||||
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
|
||||
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
|
||||
* @returns {string}
|
||||
*/
|
||||
export function createFuriganaHtml(data, readingMode, readingOverride) {
|
||||
let result = '';
|
||||
for (const term of data) {
|
||||
result += '<span class="term">';
|
||||
for (const {text, reading} of term) {
|
||||
if (reading.length > 0) {
|
||||
const reading2 = getReading(text, reading, readingMode, readingOverride);
|
||||
result += `<ruby>${text}<rt>${reading2}</rt></ruby>`;
|
||||
} else {
|
||||
result += text;
|
||||
}
|
||||
}
|
||||
result += '</span>';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ParseTextLine[]} data
|
||||
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
|
||||
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
|
||||
* @returns {string}
|
||||
*/
|
||||
export function createFuriganaPlain(data, readingMode, readingOverride) {
|
||||
let result = '';
|
||||
for (const term of data) {
|
||||
for (const {text, reading} of term) {
|
||||
if (reading.length > 0) {
|
||||
const reading2 = getReading(text, reading, readingMode, readingOverride);
|
||||
result += ` ${text}[${reading2}]`;
|
||||
} else {
|
||||
result += text;
|
||||
}
|
||||
}
|
||||
}
|
||||
result = result.trimStart();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reading
|
||||
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
|
||||
* @returns {string}
|
||||
*/
|
||||
function convertReading(reading, readingMode) {
|
||||
switch (readingMode) {
|
||||
case 'hiragana':
|
||||
return convertKatakanaToHiragana(reading);
|
||||
case 'katakana':
|
||||
return convertHiraganaToKatakana(reading);
|
||||
default:
|
||||
return reading;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string} reading
|
||||
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
|
||||
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
|
||||
* @returns {string}
|
||||
*/
|
||||
function getReading(text, reading, readingMode, readingOverride) {
|
||||
const shouldOverride = readingOverride?.type === 'term' && readingOverride.term === text && readingOverride.reading.length > 0;
|
||||
return convertReading(shouldOverride ? readingOverride.reading : reading, readingMode);
|
||||
}
|
||||
1028
vendor/yomitan/js/data/anki-note-data-creator.js
vendored
Normal file
1028
vendor/yomitan/js/data/anki-note-data-creator.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
206
vendor/yomitan/js/data/anki-template-util.js
vendored
Normal file
206
vendor/yomitan/js/data/anki-template-util.js
vendored
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets a list of field markers from the standard Handlebars template.
|
||||
* @param {import('dictionary').DictionaryEntryType} type What type of dictionary entry to get the fields for.
|
||||
* @param {string} language
|
||||
* @returns {string[]} The list of field markers.
|
||||
* @throws {Error}
|
||||
*/
|
||||
export function getStandardFieldMarkers(type, language = 'ja') {
|
||||
switch (type) {
|
||||
case 'term': {
|
||||
const markers = [
|
||||
'audio',
|
||||
'clipboard-image',
|
||||
'clipboard-text',
|
||||
'cloze-body',
|
||||
'cloze-prefix',
|
||||
'cloze-suffix',
|
||||
'conjugation',
|
||||
'dictionary',
|
||||
'dictionary-alias',
|
||||
'document-title',
|
||||
'expression',
|
||||
'frequencies',
|
||||
'frequency-harmonic-rank',
|
||||
'frequency-harmonic-occurrence',
|
||||
'frequency-average-rank',
|
||||
'frequency-average-occurrence',
|
||||
'furigana',
|
||||
'furigana-plain',
|
||||
'glossary',
|
||||
'glossary-brief',
|
||||
'glossary-no-dictionary',
|
||||
'glossary-plain',
|
||||
'glossary-plain-no-dictionary',
|
||||
'glossary-first',
|
||||
'glossary-first-brief',
|
||||
'glossary-first-no-dictionary',
|
||||
'part-of-speech',
|
||||
'phonetic-transcriptions',
|
||||
'reading',
|
||||
'screenshot',
|
||||
'search-query',
|
||||
'popup-selection-text',
|
||||
'sentence',
|
||||
'sentence-furigana',
|
||||
'sentence-furigana-plain',
|
||||
'tags',
|
||||
'url',
|
||||
];
|
||||
if (language === 'ja') {
|
||||
markers.push(
|
||||
'cloze-body-kana',
|
||||
'pitch-accents',
|
||||
'pitch-accent-graphs',
|
||||
'pitch-accent-graphs-jj',
|
||||
'pitch-accent-positions',
|
||||
'pitch-accent-categories',
|
||||
);
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
case 'kanji':
|
||||
return [
|
||||
'character',
|
||||
'clipboard-image',
|
||||
'clipboard-text',
|
||||
'cloze-body',
|
||||
'cloze-prefix',
|
||||
'cloze-suffix',
|
||||
'dictionary',
|
||||
'dictionary-alias',
|
||||
'document-title',
|
||||
'frequencies',
|
||||
'frequency-harmonic-rank',
|
||||
'frequency-harmonic-occurrence',
|
||||
'frequency-average-rank',
|
||||
'frequency-average-occurrence',
|
||||
'glossary',
|
||||
'kunyomi',
|
||||
'onyomi',
|
||||
'onyomi-hiragana',
|
||||
'screenshot',
|
||||
'search-query',
|
||||
'popup-selection-text',
|
||||
'sentence',
|
||||
'sentence-furigana',
|
||||
'sentence-furigana-plain',
|
||||
'stroke-count',
|
||||
'tags',
|
||||
'url',
|
||||
];
|
||||
default:
|
||||
throw new Error(`Unsupported type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @param {import('dictionary-importer').Summary[]} dictionaryInfo
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getDynamicTemplates(options, dictionaryInfo) {
|
||||
let dynamicTemplates = '\n';
|
||||
for (const dictionary of options.dictionaries) {
|
||||
const currentDictionaryInfo = dictionaryInfo.find(({title}) => title === dictionary.name);
|
||||
if (!dictionary.enabled) { continue; }
|
||||
const totalTerms = currentDictionaryInfo?.counts?.terms?.total;
|
||||
if (totalTerms && totalTerms > 0) {
|
||||
dynamicTemplates += `
|
||||
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}"}}
|
||||
{{~> glossary selectedDictionary='${escapeDictName(dictionary.name)}'}}
|
||||
{{/inline}}
|
||||
|
||||
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-no-dictionary"}}
|
||||
{{~> glossary selectedDictionary='${escapeDictName(dictionary.name)}' noDictionaryTag=true}}
|
||||
{{/inline}}
|
||||
|
||||
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-brief"}}
|
||||
{{~> glossary selectedDictionary='${escapeDictName(dictionary.name)}' brief=true}}
|
||||
{{/inline}}
|
||||
|
||||
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-plain"}}
|
||||
{{~> glossary-plain selectedDictionary='${escapeDictName(dictionary.name)}'}}
|
||||
{{/inline}}
|
||||
|
||||
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-plain-no-dictionary"}}
|
||||
{{~> glossary-plain-no-dictionary selectedDictionary='${escapeDictName(dictionary.name)}' noDictionaryTag=true}}
|
||||
{{/inline}}
|
||||
`;
|
||||
}
|
||||
const totalMeta = currentDictionaryInfo?.counts?.termMeta;
|
||||
if (totalMeta && totalMeta.freq && totalMeta.freq > 0) {
|
||||
dynamicTemplates += `
|
||||
{{#*inline "single-frequency-number-${getKebabCase(dictionary.name)}"}}
|
||||
{{~> single-frequency-number selectedDictionary='${escapeDictName(dictionary.name)}'}}
|
||||
{{/inline}}
|
||||
{{#*inline "single-frequency-${getKebabCase(dictionary.name)}"}}
|
||||
{{~> frequencies selectedDictionary='${escapeDictName(dictionary.name)}'}}
|
||||
{{/inline}}
|
||||
`;
|
||||
}
|
||||
}
|
||||
return dynamicTemplates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').DictionariesOptions} dictionaries
|
||||
* @param {import('dictionary-importer').Summary[]} dictionaryInfo
|
||||
* @returns {string[]} The list of field markers.
|
||||
*/
|
||||
export function getDynamicFieldMarkers(dictionaries, dictionaryInfo) {
|
||||
const markers = [];
|
||||
for (const dictionary of dictionaries) {
|
||||
const currentDictionaryInfo = dictionaryInfo.find(({title}) => title === dictionary.name);
|
||||
if (!dictionary.enabled) { continue; }
|
||||
const totalTerms = currentDictionaryInfo?.counts?.terms?.total;
|
||||
if (totalTerms && totalTerms > 0) {
|
||||
markers.push(`single-glossary-${getKebabCase(dictionary.name)}`);
|
||||
}
|
||||
const totalMeta = currentDictionaryInfo?.counts?.termMeta;
|
||||
if (totalMeta && totalMeta.freq && totalMeta.freq > 0) {
|
||||
markers.push(`single-frequency-number-${getKebabCase(dictionary.name)}`);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getKebabCase(str) {
|
||||
return str
|
||||
.replace(/[\s_\u3000]/g, '-')
|
||||
.replace(/[^\p{L}\p{N}-]/gu, '')
|
||||
.replace(/--+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeDictName(name) {
|
||||
return name
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, '\\\'');
|
||||
}
|
||||
128
vendor/yomitan/js/data/anki-util.js
vendored
Normal file
128
vendor/yomitan/js/data/anki-util.js
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/** @type {RegExp} @readonly */
|
||||
const markerPattern = /\{([\p{Letter}\p{Number}_-]+)\}/gu;
|
||||
|
||||
/**
|
||||
* Gets the root deck name of a full deck name. If the deck is a root deck,
|
||||
* the same name is returned. Nested decks are separated using '::'.
|
||||
* @param {string} deckName A string of the deck name.
|
||||
* @returns {string} A string corresponding to the name of the root deck.
|
||||
*/
|
||||
export function getRootDeckName(deckName) {
|
||||
const index = deckName.indexOf('::');
|
||||
return index >= 0 ? deckName.substring(0, index) : deckName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not any marker is contained in a string.
|
||||
* @param {string} string A string to check.
|
||||
* @returns {boolean} `true` if the text contains an Anki field marker, `false` otherwise.
|
||||
*/
|
||||
export function stringContainsAnyFieldMarker(string) {
|
||||
const result = markerPattern.test(string);
|
||||
markerPattern.lastIndex = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all markers that are contained in a string.
|
||||
* @param {string} string A string to check.
|
||||
* @returns {string[]} An array of marker strings.
|
||||
*/
|
||||
export function getFieldMarkers(string) {
|
||||
const pattern = markerPattern;
|
||||
const markers = [];
|
||||
while (true) {
|
||||
const match = pattern.exec(string);
|
||||
if (match === null) { break; }
|
||||
markers.push(match[1]);
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a regular expression which can be used to find markers in a string.
|
||||
* @param {boolean} global Whether or not the regular expression should have the global flag.
|
||||
* @returns {RegExp} A new `RegExp` instance.
|
||||
*/
|
||||
export function cloneFieldMarkerPattern(global) {
|
||||
return new RegExp(markerPattern.source, global ? 'gu' : 'u');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not a note object is valid.
|
||||
* @param {import('anki').Note} note A note object to check.
|
||||
* @returns {boolean} `true` if the note is valid, `false` otherwise.
|
||||
*/
|
||||
export function isNoteDataValid(note) {
|
||||
if (!isObjectNotArray(note)) { return false; }
|
||||
const {fields, deckName, modelName} = note;
|
||||
return (
|
||||
typeof deckName === 'string' && deckName.length > 0 &&
|
||||
typeof modelName === 'string' && modelName.length > 0 &&
|
||||
Object.entries(fields).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export const INVALID_NOTE_ID = -1;
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} prefix
|
||||
* @param {string} extension
|
||||
* @param {number} timestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateAnkiNoteMediaFileName(prefix, extension, timestamp) {
|
||||
let fileName = prefix;
|
||||
|
||||
fileName += `_${ankNoteDateToString(new Date(timestamp))}`;
|
||||
fileName += extension;
|
||||
|
||||
fileName = replaceInvalidFileNameCharacters(fileName);
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileName
|
||||
* @returns {string}
|
||||
*/
|
||||
function replaceInvalidFileNameCharacters(fileName) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return fileName.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
function ankNoteDateToString(date) {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = date.getUTCMonth().toString().padStart(2, '0');
|
||||
const day = date.getUTCDate().toString().padStart(2, '0');
|
||||
const hours = date.getUTCHours().toString().padStart(2, '0');
|
||||
const minutes = date.getUTCMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getUTCSeconds().toString().padStart(2, '0');
|
||||
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0');
|
||||
return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}-${milliseconds}`;
|
||||
}
|
||||
72
vendor/yomitan/js/data/array-buffer-util.js
vendored
Normal file
72
vendor/yomitan/js/data/array-buffer-util.js
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decodes the contents of an ArrayBuffer using UTF8.
|
||||
* @param {ArrayBuffer} arrayBuffer The input ArrayBuffer.
|
||||
* @returns {string} A UTF8-decoded string.
|
||||
*/
|
||||
export function arrayBufferUtf8Decode(arrayBuffer) {
|
||||
try {
|
||||
return new TextDecoder('utf-8').decode(arrayBuffer);
|
||||
} catch (e) {
|
||||
return decodeURIComponent(escape(arrayBufferToBinaryString(arrayBuffer)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the contents of an ArrayBuffer to a base64 string.
|
||||
* @param {ArrayBuffer} arrayBuffer The input ArrayBuffer.
|
||||
* @returns {string} A base64 string representing the binary content.
|
||||
*/
|
||||
export function arrayBufferToBase64(arrayBuffer) {
|
||||
return btoa(arrayBufferToBinaryString(arrayBuffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the contents of an ArrayBuffer to a binary string.
|
||||
* @param {ArrayBuffer} arrayBuffer The input ArrayBuffer.
|
||||
* @returns {string} A string representing the binary content.
|
||||
*/
|
||||
export function arrayBufferToBinaryString(arrayBuffer) {
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
try {
|
||||
return String.fromCharCode(...bytes);
|
||||
} catch (e) {
|
||||
let binary = '';
|
||||
for (let i = 0, ii = bytes.byteLength; i < ii; ++i) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a base64 string to an ArrayBuffer.
|
||||
* @param {string} content The binary content string encoded in base64.
|
||||
* @returns {ArrayBuffer} A new `ArrayBuffer` object corresponding to the specified content.
|
||||
*/
|
||||
export function base64ToArrayBuffer(content) {
|
||||
const binaryContent = atob(content);
|
||||
const length = binaryContent.length;
|
||||
const array = new Uint8Array(length);
|
||||
for (let i = 0; i < length; ++i) {
|
||||
array[i] = binaryContent.charCodeAt(i);
|
||||
}
|
||||
return array.buffer;
|
||||
}
|
||||
618
vendor/yomitan/js/data/database.js
vendored
Normal file
618
vendor/yomitan/js/data/database.js
vendored
Normal file
@@ -0,0 +1,618 @@
|
||||
/*
|
||||
* 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 {toError} from '../core/to-error.js';
|
||||
|
||||
/**
|
||||
* @template {string} TObjectStoreName
|
||||
*/
|
||||
export class Database {
|
||||
constructor() {
|
||||
/** @type {?IDBDatabase} */
|
||||
this._db = null;
|
||||
/** @type {boolean} */
|
||||
this._isOpening = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} databaseName
|
||||
* @param {number} version
|
||||
* @param {import('database').StructureDefinition<TObjectStoreName>[]?} structure
|
||||
*/
|
||||
async open(databaseName, version, structure) {
|
||||
if (this._db !== null) {
|
||||
throw new Error('Database already open');
|
||||
}
|
||||
if (this._isOpening) {
|
||||
throw new Error('Already opening');
|
||||
}
|
||||
|
||||
try {
|
||||
this._isOpening = true;
|
||||
this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => {
|
||||
if (structure !== null) {
|
||||
this._upgrade(db, transaction, oldVersion, structure);
|
||||
}
|
||||
});
|
||||
if (this._db.objectStoreNames.length === 0) {
|
||||
this.close();
|
||||
await Database.deleteDatabase(databaseName);
|
||||
this._isOpening = false;
|
||||
await this.open(databaseName, version, structure);
|
||||
}
|
||||
} finally {
|
||||
this._isOpening = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {Error}
|
||||
*/
|
||||
close() {
|
||||
if (this._db === null) {
|
||||
throw new Error('Database is not open');
|
||||
}
|
||||
|
||||
this._db.close();
|
||||
this._db = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the database opening is in process.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOpening() {
|
||||
return this._isOpening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the database is fully opened.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOpen() {
|
||||
return this._db !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new transaction with the given mode ("readonly" or "readwrite") and scope which can be a single object store name or an array of names.
|
||||
* @param {string[]} storeNames
|
||||
* @param {IDBTransactionMode} mode
|
||||
* @returns {IDBTransaction}
|
||||
* @throws {Error}
|
||||
*/
|
||||
transaction(storeNames, mode) {
|
||||
if (this._db === null) {
|
||||
throw new Error(this._isOpening ? 'Database not ready' : 'Database not open');
|
||||
}
|
||||
try {
|
||||
return this._db.transaction(storeNames, mode);
|
||||
} catch (e) {
|
||||
throw new Error(toError(e).message + '\nDatabase transaction error, you may need to Delete All dictionaries to reset the database or manually delete the Indexed DB database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add items in bulk to the object store.
|
||||
* _count_ items will be added, starting from _start_ index of _items_ list.
|
||||
* @param {TObjectStoreName} objectStoreName
|
||||
* @param {unknown[]} items List of items to add.
|
||||
* @param {number} start Start index. Added items begin at _items_[_start_].
|
||||
* @param {number} count Count of items to add.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
bulkAdd(objectStoreName, items, start, count) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (start + count > items.length) {
|
||||
count = items.length - start;
|
||||
}
|
||||
|
||||
if (count <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
for (let i = start, ii = start + count; i < ii; ++i) {
|
||||
objectStore.add(items[i]);
|
||||
}
|
||||
transaction.commit();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single item and return a promise containing the resulting primaryKey.
|
||||
* Holding onto the result value makes the GC not clean up until much later even if the value is not used.
|
||||
* Only call this method if the primaryKey of the added value is required.
|
||||
* @param {TObjectStoreName} objectStoreName
|
||||
* @param {unknown} item Item to add.
|
||||
* @returns {Promise<IDBRequest<IDBValidKey>>}
|
||||
*/
|
||||
addWithResult(objectStoreName, item) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._readWriteTransaction([objectStoreName], () => {}, reject);
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
const result = objectStore.add(item);
|
||||
transaction.commit();
|
||||
resolve(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update items in bulk to the object store.
|
||||
* Items that do not exist will be added.
|
||||
* _count_ items will be updated, starting from _start_ index of _items_ list.
|
||||
* @param {TObjectStoreName} objectStoreName
|
||||
* @param {import('dictionary-database').DatabaseUpdateItem[]} items List of items to update.
|
||||
* @param {number} start Start index. Updated items begin at _items_[_start_].
|
||||
* @param {number} count Count of items to update.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
bulkUpdate(objectStoreName, items, start, count) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (start + count > items.length) {
|
||||
count = items.length - start;
|
||||
}
|
||||
|
||||
if (count <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
|
||||
for (let i = start, ii = start + count; i < ii; ++i) {
|
||||
objectStore.put(items[i].data, items[i].primaryKey);
|
||||
}
|
||||
transaction.commit();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TData=unknown]
|
||||
* @template [TResult=unknown]
|
||||
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
|
||||
* @param {?IDBValidKey|IDBKeyRange} query
|
||||
* @param {(results: TResult[], data: TData) => void} onSuccess
|
||||
* @param {(reason: unknown, data: TData) => void} onError
|
||||
* @param {TData} data
|
||||
*/
|
||||
getAll(objectStoreOrIndex, query, onSuccess, onError, data) {
|
||||
if (typeof objectStoreOrIndex.getAll === 'function') {
|
||||
this._getAllFast(objectStoreOrIndex, query, onSuccess, onError, data);
|
||||
} else {
|
||||
this._getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onError, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
|
||||
* @param {IDBValidKey|IDBKeyRange} query
|
||||
* @param {(value: IDBValidKey[]) => void} onSuccess
|
||||
* @param {(reason?: unknown) => void} onError
|
||||
*/
|
||||
getAllKeys(objectStoreOrIndex, query, onSuccess, onError) {
|
||||
if (typeof objectStoreOrIndex.getAllKeys === 'function') {
|
||||
this._getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError);
|
||||
} else {
|
||||
this._getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TPredicateArg=unknown]
|
||||
* @template [TResult=unknown]
|
||||
* @template [TResultDefault=unknown]
|
||||
* @param {TObjectStoreName} objectStoreName
|
||||
* @param {?string} indexName
|
||||
* @param {?IDBValidKey|IDBKeyRange} query
|
||||
* @param {?((value: TResult|TResultDefault, predicateArg: TPredicateArg) => boolean)} predicate
|
||||
* @param {TPredicateArg} predicateArg
|
||||
* @param {TResultDefault} defaultValue
|
||||
* @returns {Promise<TResult|TResultDefault>}
|
||||
*/
|
||||
find(objectStoreName, indexName, query, predicate, predicateArg, defaultValue) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.transaction([objectStoreName], 'readonly');
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore;
|
||||
this.findFirst(objectStoreOrIndex, query, resolve, reject, null, predicate, predicateArg, defaultValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TData=unknown]
|
||||
* @template [TPredicateArg=unknown]
|
||||
* @template [TResult=unknown]
|
||||
* @template [TResultDefault=unknown]
|
||||
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
|
||||
* @param {?IDBValidKey|IDBKeyRange} query
|
||||
* @param {(value: TResult|TResultDefault, data: TData) => void} resolve
|
||||
* @param {(reason: unknown, data: TData) => void} reject
|
||||
* @param {TData} data
|
||||
* @param {?((value: TResult, predicateArg: TPredicateArg) => boolean)} predicate
|
||||
* @param {TPredicateArg} predicateArg
|
||||
* @param {TResultDefault} defaultValue
|
||||
*/
|
||||
findFirst(objectStoreOrIndex, query, resolve, reject, data, predicate, predicateArg, defaultValue) {
|
||||
const noPredicate = (typeof predicate !== 'function');
|
||||
const request = objectStoreOrIndex.openCursor(query, 'next');
|
||||
request.onerror = (e) => reject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data);
|
||||
request.onsuccess = (e) => {
|
||||
const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result;
|
||||
if (cursor) {
|
||||
/** @type {unknown} */
|
||||
const value = cursor.value;
|
||||
if (noPredicate || predicate(/** @type {TResult} */ (value), predicateArg)) {
|
||||
resolve(/** @type {TResult} */ (value), data);
|
||||
} else {
|
||||
cursor.continue();
|
||||
}
|
||||
} else {
|
||||
resolve(defaultValue, data);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('database').CountTarget[]} targets
|
||||
* @param {(results: number[]) => void} resolve
|
||||
* @param {(reason?: unknown) => void} reject
|
||||
*/
|
||||
bulkCount(targets, resolve, reject) {
|
||||
const targetCount = targets.length;
|
||||
if (targetCount <= 0) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let completedCount = 0;
|
||||
/** @type {number[]} */
|
||||
const results = new Array(targetCount).fill(null);
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
* @returns {void}
|
||||
*/
|
||||
const onError = (e) => reject(/** @type {IDBRequest<number>} */ (e.target).error);
|
||||
/**
|
||||
* @param {Event} e
|
||||
* @param {number} index
|
||||
*/
|
||||
const onSuccess = (e, index) => {
|
||||
const count = /** @type {IDBRequest<number>} */ (e.target).result;
|
||||
results[index] = count;
|
||||
if (++completedCount >= targetCount) {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < targetCount; ++i) {
|
||||
const index = i;
|
||||
const [objectStoreOrIndex, query] = targets[i];
|
||||
const request = objectStoreOrIndex.count(query);
|
||||
request.onerror = onError;
|
||||
request.onsuccess = (e) => onSuccess(e, index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes records in store with the given key or in the given key range in query.
|
||||
* @param {TObjectStoreName} objectStoreName
|
||||
* @param {IDBValidKey|IDBKeyRange} key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
delete(objectStoreName, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
objectStore.delete(key);
|
||||
transaction.commit();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete items in bulk from the object store.
|
||||
* @param {TObjectStoreName} objectStoreName
|
||||
* @param {?string} indexName
|
||||
* @param {IDBKeyRange} query
|
||||
* @param {?(keys: IDBValidKey[]) => IDBValidKey[]} filterKeys
|
||||
* @param {?(completedCount: number, totalCount: number) => void} onProgress
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
bulkDelete(objectStoreName, indexName, query, filterKeys = null, onProgress = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore;
|
||||
|
||||
/**
|
||||
* @param {IDBValidKey[]} keys
|
||||
*/
|
||||
const onGetKeys = (keys) => {
|
||||
try {
|
||||
if (typeof filterKeys === 'function') {
|
||||
keys = filterKeys(keys);
|
||||
}
|
||||
this._bulkDeleteInternal(objectStore, keys, 1000, 0, onProgress, (error) => {
|
||||
if (error !== null) {
|
||||
transaction.commit();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to delete the named database.
|
||||
* If the database already exists and there are open connections that don't close in response to a versionchange event, the request will be blocked until all they close.
|
||||
* If the request is successful request's result will be null.
|
||||
* @param {string} databaseName
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static deleteDatabase(databaseName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(databaseName);
|
||||
request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onblocked = () => reject(new Error('Database deletion blocked'));
|
||||
});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} version
|
||||
* @param {import('database').UpdateFunction} onUpgradeNeeded
|
||||
* @returns {Promise<IDBDatabase>}
|
||||
*/
|
||||
_open(name, version, onUpgradeNeeded) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, version);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
try {
|
||||
const transaction = /** @type {IDBTransaction} */ (request.transaction);
|
||||
transaction.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error);
|
||||
onUpgradeNeeded(request.result, transaction, event.oldVersion, event.newVersion);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IDBDatabase} db
|
||||
* @param {IDBTransaction} transaction
|
||||
* @param {number} oldVersion
|
||||
* @param {import('database').StructureDefinition<TObjectStoreName>[]} upgrades
|
||||
*/
|
||||
_upgrade(db, transaction, oldVersion, upgrades) {
|
||||
for (const {version, stores} of upgrades) {
|
||||
if (oldVersion >= version) { continue; }
|
||||
|
||||
/** @type {[objectStoreName: string, value: import('database').StoreDefinition][]} */
|
||||
const entries = Object.entries(stores);
|
||||
for (const [objectStoreName, {primaryKey, indices}] of entries) {
|
||||
const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames;
|
||||
const objectStore = (
|
||||
this._listContains(existingObjectStoreNames, objectStoreName) ?
|
||||
transaction.objectStore(objectStoreName) :
|
||||
db.createObjectStore(objectStoreName, primaryKey)
|
||||
);
|
||||
const existingIndexNames = objectStore.indexNames;
|
||||
|
||||
for (const indexName of indices) {
|
||||
if (this._listContains(existingIndexNames, indexName)) { continue; }
|
||||
|
||||
objectStore.createIndex(indexName, indexName, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMStringList} list
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_listContains(list, value) {
|
||||
for (let i = 0, ii = list.length; i < ii; ++i) {
|
||||
if (list[i] === value) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TData=unknown]
|
||||
* @template [TResult=unknown]
|
||||
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
|
||||
* @param {?IDBValidKey|IDBKeyRange} query
|
||||
* @param {(results: TResult[], data: TData) => void} onSuccess
|
||||
* @param {(reason: unknown, data: TData) => void} onReject
|
||||
* @param {TData} data
|
||||
*/
|
||||
_getAllFast(objectStoreOrIndex, query, onSuccess, onReject, data) {
|
||||
const request = objectStoreOrIndex.getAll(query);
|
||||
request.onerror = (e) => {
|
||||
const target = /** @type {IDBRequest<TResult[]>} */ (e.target);
|
||||
onReject(target.error, data);
|
||||
};
|
||||
request.onsuccess = (e) => {
|
||||
const target = /** @type {IDBRequest<TResult[]>} */ (e.target);
|
||||
onSuccess(target.result, data);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TData=unknown]
|
||||
* @template [TResult=unknown]
|
||||
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
|
||||
* @param {?IDBValidKey|IDBKeyRange} query
|
||||
* @param {(results: TResult[], data: TData) => void} onSuccess
|
||||
* @param {(reason: unknown, data: TData) => void} onReject
|
||||
* @param {TData} data
|
||||
*/
|
||||
_getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onReject, data) {
|
||||
/** @type {TResult[]} */
|
||||
const results = [];
|
||||
const request = objectStoreOrIndex.openCursor(query, 'next');
|
||||
request.onerror = (e) => onReject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data);
|
||||
request.onsuccess = (e) => {
|
||||
const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result;
|
||||
if (cursor) {
|
||||
/** @type {unknown} */
|
||||
const value = cursor.value;
|
||||
results.push(/** @type {TResult} */ (value));
|
||||
cursor.continue();
|
||||
} else {
|
||||
onSuccess(results, data);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
|
||||
* @param {IDBValidKey|IDBKeyRange} query
|
||||
* @param {(value: IDBValidKey[]) => void} onSuccess
|
||||
* @param {(reason?: unknown) => void} onError
|
||||
*/
|
||||
_getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError) {
|
||||
const request = objectStoreOrIndex.getAllKeys(query);
|
||||
request.onerror = (e) => onError(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).error);
|
||||
request.onsuccess = (e) => onSuccess(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
|
||||
* @param {IDBValidKey|IDBKeyRange} query
|
||||
* @param {(value: IDBValidKey[]) => void} onSuccess
|
||||
* @param {(reason?: unknown) => void} onError
|
||||
*/
|
||||
_getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError) {
|
||||
/** @type {IDBValidKey[]} */
|
||||
const results = [];
|
||||
const request = objectStoreOrIndex.openKeyCursor(query, 'next');
|
||||
request.onerror = (e) => onError(/** @type {IDBRequest<?IDBCursor>} */ (e.target).error);
|
||||
request.onsuccess = (e) => {
|
||||
const cursor = /** @type {IDBRequest<?IDBCursor>} */ (e.target).result;
|
||||
if (cursor) {
|
||||
results.push(cursor.primaryKey);
|
||||
cursor.continue();
|
||||
} else {
|
||||
onSuccess(results);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} objectStore The object store from which items are being deleted.
|
||||
* @param {IDBValidKey[]} keys An array of keys to delete from the object store.
|
||||
* @param {number} maxActiveRequests The maximum number of concurrent requests.
|
||||
* @param {number} maxActiveRequestsForContinue The maximum number of requests that can be active before the next set of requests is started.
|
||||
* For example:
|
||||
* - If this value is `0`, all of the `maxActiveRequests` requests must complete before another group of `maxActiveRequests` is started off.
|
||||
* - If the value is greater than or equal to `maxActiveRequests-1`, every time a single request completes, a new single request will be started.
|
||||
* @param {?(completedCount: number, totalCount: number) => void} onProgress An optional progress callback function.
|
||||
* @param {(error: ?Error) => void} onComplete A function which is called after all operations have finished.
|
||||
* If an error occured, the `error` parameter will be non-`null`. Otherwise, it will be `null`.
|
||||
* @throws {Error} An error is thrown if the input parameters are invalid.
|
||||
*/
|
||||
_bulkDeleteInternal(objectStore, keys, maxActiveRequests, maxActiveRequestsForContinue, onProgress, onComplete) {
|
||||
if (maxActiveRequests <= 0) { throw new Error(`maxActiveRequests has an invalid value: ${maxActiveRequests}`); }
|
||||
if (maxActiveRequestsForContinue < 0) { throw new Error(`maxActiveRequestsForContinue has an invalid value: ${maxActiveRequestsForContinue}`); }
|
||||
|
||||
const count = keys.length;
|
||||
if (count === 0) {
|
||||
onComplete(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let completedCount = 0;
|
||||
let completed = false;
|
||||
let index = 0;
|
||||
let active = 0;
|
||||
|
||||
const onSuccess = () => {
|
||||
if (completed) { return; }
|
||||
--active;
|
||||
++completedCount;
|
||||
if (onProgress !== null) {
|
||||
try {
|
||||
onProgress(completedCount, count);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
if (completedCount >= count) {
|
||||
completed = true;
|
||||
onComplete(null);
|
||||
} else if (active <= maxActiveRequestsForContinue) {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
const onError = (event) => {
|
||||
if (completed) { return; }
|
||||
completed = true;
|
||||
const request = /** @type {IDBRequest<undefined>} */ (event.target);
|
||||
const {error} = request;
|
||||
onComplete(error);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
for (; index < count && active < maxActiveRequests; ++index) {
|
||||
const key = keys[index];
|
||||
const request = objectStore.delete(key);
|
||||
request.onsuccess = onSuccess;
|
||||
request.onerror = onError;
|
||||
++active;
|
||||
}
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} storeNames
|
||||
* @param {() => void} resolve
|
||||
* @param {(reason?: unknown) => void} reject
|
||||
* @returns {IDBTransaction}
|
||||
*/
|
||||
_readWriteTransaction(storeNames, resolve, reject) {
|
||||
const transaction = this.transaction(storeNames, 'readwrite');
|
||||
transaction.onerror = (e) => reject(/** @type {IDBTransaction} */ (e.target).error);
|
||||
transaction.onabort = () => reject(new Error('Transaction aborted'));
|
||||
transaction.oncomplete = () => resolve();
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
1360
vendor/yomitan/js/data/json-schema.js
vendored
Normal file
1360
vendor/yomitan/js/data/json-schema.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1853
vendor/yomitan/js/data/options-util.js
vendored
Normal file
1853
vendor/yomitan/js/data/options-util.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
142
vendor/yomitan/js/data/permissions-util.js
vendored
Normal file
142
vendor/yomitan/js/data/permissions-util.js
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 {getFieldMarkers} from './anki-util.js';
|
||||
|
||||
/**
|
||||
* This function returns whether an Anki field marker might require clipboard permissions.
|
||||
* This is speculative and may not guarantee that the field marker actually does require the permission,
|
||||
* as the custom handlebars template is not deeply inspected.
|
||||
* @param {string} marker
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function ankiFieldMarkerMayUseClipboard(marker) {
|
||||
switch (marker) {
|
||||
case 'clipboard-image':
|
||||
case 'clipboard-text':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.permissions.Permissions} permissions
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export function hasPermissions(permissions) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.permissions.contains(permissions, (result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.permissions.Permissions} permissions
|
||||
* @param {boolean} shouldHave
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export function setPermissionsGranted(permissions, shouldHave) {
|
||||
return (
|
||||
shouldHave ?
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.permissions.request(permissions, (result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}) :
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.permissions.remove(permissions, (result) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
if (e) {
|
||||
reject(new Error(e.message));
|
||||
} else {
|
||||
resolve(!result);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<chrome.permissions.Permissions>}
|
||||
*/
|
||||
export function getAllPermissions() {
|
||||
// Electron workaround - chrome.permissions.getAll() not available
|
||||
return Promise.resolve({
|
||||
origins: ["<all_urls>"],
|
||||
permissions: ["clipboardWrite", "storage", "unlimitedStorage", "scripting", "contextMenus"]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldValue
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getRequiredPermissionsForAnkiFieldValue(fieldValue) {
|
||||
const markers = getFieldMarkers(fieldValue);
|
||||
for (const marker of markers) {
|
||||
if (ankiFieldMarkerMayUseClipboard(marker)) {
|
||||
return ['clipboardRead'];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {chrome.permissions.Permissions} permissions
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasRequiredPermissionsForOptions(permissions, options) {
|
||||
const permissionsSet = new Set(permissions.permissions);
|
||||
|
||||
if (!permissionsSet.has('nativeMessaging') && (options.parsing.enableMecabParser || options.general.enableYomitanApi)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!permissionsSet.has('clipboardRead')) {
|
||||
if (options.clipboard.enableBackgroundMonitor || options.clipboard.enableSearchPageMonitor) {
|
||||
return false;
|
||||
}
|
||||
const fieldsList = options.anki.cardFormats.map((cardFormat) => cardFormat.fields);
|
||||
|
||||
for (const fields of fieldsList) {
|
||||
for (const {value: fieldValue} of Object.values(fields)) {
|
||||
const markers = getFieldMarkers(fieldValue);
|
||||
for (const marker of markers) {
|
||||
if (ankiFieldMarkerMayUseClipboard(marker)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
37
vendor/yomitan/js/data/profiles-util.js
vendored
Normal file
37
vendor/yomitan/js/data/profiles-util.js
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {number} direction
|
||||
* @param {import('../application.js').Application} application
|
||||
*/
|
||||
export async function setProfile(direction, application) {
|
||||
const optionsFull = await application.api.optionsGetFull();
|
||||
|
||||
const profileCount = optionsFull.profiles.length;
|
||||
const newProfile = (optionsFull.profileCurrent + direction + profileCount) % profileCount;
|
||||
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'profileCurrent',
|
||||
value: newProfile,
|
||||
scope: 'global',
|
||||
optionsContext: null,
|
||||
};
|
||||
await application.api.modifySettings([modification], 'search');
|
||||
}
|
||||
81
vendor/yomitan/js/data/string-util.js
vendored
Normal file
81
vendor/yomitan/js/data/string-util.js
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reads code points from a string in the forward direction.
|
||||
* @param {string} text The text to read the code points from.
|
||||
* @param {number} position The index of the first character to read.
|
||||
* @param {number} count The number of code points to read.
|
||||
* @returns {string} The code points from the string.
|
||||
*/
|
||||
export function readCodePointsForward(text, position, count) {
|
||||
const textLength = text.length;
|
||||
let result = '';
|
||||
for (; count > 0; --count) {
|
||||
const char = text[position];
|
||||
result += char;
|
||||
if (++position >= textLength) { break; }
|
||||
const charCode = char.charCodeAt(0);
|
||||
if (charCode >= 0xd800 && charCode < 0xdc00) { // charCode is a high surrogate code
|
||||
const char2 = text[position];
|
||||
const charCode2 = char2.charCodeAt(0);
|
||||
if (charCode2 >= 0xdc00 && charCode2 < 0xe000) { // charCode2 is a low surrogate code
|
||||
result += char2;
|
||||
if (++position >= textLength) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads code points from a string in the backward direction.
|
||||
* @param {string} text The text to read the code points from.
|
||||
* @param {number} position The index of the first character to read.
|
||||
* @param {number} count The number of code points to read.
|
||||
* @returns {string} The code points from the string.
|
||||
*/
|
||||
export function readCodePointsBackward(text, position, count) {
|
||||
let result = '';
|
||||
for (; count > 0; --count) {
|
||||
const char = text[position];
|
||||
result = char + result;
|
||||
if (--position < 0) { break; }
|
||||
const charCode = char.charCodeAt(0);
|
||||
if (charCode >= 0xdc00 && charCode < 0xe000) { // charCode is a low surrogate code
|
||||
const char2 = text[position];
|
||||
const charCode2 = char2.charCodeAt(0);
|
||||
if (charCode2 >= 0xd800 && charCode2 < 0xdc00) { // charCode2 is a high surrogate code
|
||||
result = char2 + result;
|
||||
if (--position < 0) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims and condenses trailing whitespace and adds a space on the end if it needed trimming.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function trimTrailingWhitespacePlusSpace(text) {
|
||||
// Consense multiple leading and trailing newlines into one newline
|
||||
// Trim trailing whitespace excluding newlines
|
||||
return text.replaceAll(/(\n+$|^\n+)/g, '\n').replaceAll(/[^\S\n]+$/g, ' ');
|
||||
}
|
||||
498
vendor/yomitan/js/dictionary/dictionary-data-util.js
vendored
Normal file
498
vendor/yomitan/js/dictionary/dictionary-data-util.js
vendored
Normal file
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* 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 {getFrequencyHarmonic} from '../data/anki-note-data-creator.js';
|
||||
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
|
||||
* @returns {import('dictionary-data-util').TagGroup[]}
|
||||
*/
|
||||
export function groupTermTags(dictionaryEntry) {
|
||||
const {headwords} = dictionaryEntry;
|
||||
const headwordCount = headwords.length;
|
||||
const uniqueCheck = (headwordCount > 1);
|
||||
/** @type {Map<string, number>} */
|
||||
const resultsIndexMap = new Map();
|
||||
const results = [];
|
||||
for (let i = 0; i < headwordCount; ++i) {
|
||||
const {tags} = headwords[i];
|
||||
for (const tag of tags) {
|
||||
if (uniqueCheck) {
|
||||
const {name, category, content, dictionaries} = tag;
|
||||
const key = createMapKey([name, category, content, dictionaries]);
|
||||
const index = resultsIndexMap.get(key);
|
||||
if (typeof index !== 'undefined') {
|
||||
const existingItem = results[index];
|
||||
existingItem.headwordIndices.push(i);
|
||||
continue;
|
||||
}
|
||||
resultsIndexMap.set(key, results.length);
|
||||
}
|
||||
|
||||
const item = {tag, headwordIndices: [i]};
|
||||
results.push(item);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
|
||||
* @param {import('dictionary-importer').Summary[]} dictionaryInfo
|
||||
* @returns {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').TermFrequency>[]}
|
||||
*/
|
||||
export function groupTermFrequencies(dictionaryEntry, dictionaryInfo) {
|
||||
const {headwords, frequencies: sourceFrequencies} = dictionaryEntry;
|
||||
|
||||
/** @type {import('dictionary-data-util').TermFrequenciesMap1} */
|
||||
const map1 = new Map();
|
||||
/** @type {Map<string, string>} */
|
||||
const aliasMap = new Map();
|
||||
for (const {headwordIndex, dictionary, dictionaryAlias, hasReading, frequency, displayValue} of sourceFrequencies) {
|
||||
const {term, reading} = headwords[headwordIndex];
|
||||
|
||||
let map2 = map1.get(dictionary);
|
||||
if (typeof map2 === 'undefined') {
|
||||
map2 = new Map();
|
||||
map1.set(dictionary, map2);
|
||||
aliasMap.set(dictionary, dictionaryAlias);
|
||||
}
|
||||
|
||||
const readingKey = hasReading ? reading : null;
|
||||
const key = createMapKey([term, readingKey]);
|
||||
let frequencyData = map2.get(key);
|
||||
if (typeof frequencyData === 'undefined') {
|
||||
frequencyData = {term, reading: readingKey, values: new Map()};
|
||||
map2.set(key, frequencyData);
|
||||
}
|
||||
|
||||
frequencyData.values.set(createMapKey([frequency, displayValue]), {frequency, displayValue});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const [dictionary, map2] of map1.entries()) {
|
||||
/** @type {import('dictionary-data-util').TermFrequency[]} */
|
||||
const frequencies = [];
|
||||
const dictionaryAlias = aliasMap.get(dictionary) ?? dictionary;
|
||||
for (const {term, reading, values} of map2.values()) {
|
||||
const termFrequency = {
|
||||
term,
|
||||
reading,
|
||||
values: [...values.values()],
|
||||
};
|
||||
frequencies.push(termFrequency);
|
||||
}
|
||||
const currentDictionaryInfo = dictionaryInfo.find(({title}) => title === dictionary);
|
||||
const freqCount = currentDictionaryInfo?.counts?.termMeta.freq ?? 0;
|
||||
results.push({dictionary, frequencies, dictionaryAlias, freqCount});
|
||||
}
|
||||
|
||||
const averageFrequencies = [];
|
||||
for (let i = 0; i < dictionaryEntry.headwords.length; i++) {
|
||||
const averageFrequency = getFrequencyHarmonic(dictionaryEntry, i);
|
||||
averageFrequencies.push({
|
||||
term: dictionaryEntry.headwords[i].term,
|
||||
reading: dictionaryEntry.headwords[i].reading,
|
||||
values: [{
|
||||
frequency: averageFrequency,
|
||||
displayValue: averageFrequency.toString(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
results.push({
|
||||
dictionary: 'Average',
|
||||
frequencies: averageFrequencies,
|
||||
dictionaryAlias: 'Average',
|
||||
freqCount: averageFrequencies.length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').KanjiFrequency[]} sourceFrequencies
|
||||
* @param {import('dictionary-importer').Summary[]} dictionaryInfo
|
||||
* @returns {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').KanjiFrequency>[]}
|
||||
*/
|
||||
export function groupKanjiFrequencies(sourceFrequencies, dictionaryInfo) {
|
||||
/** @type {import('dictionary-data-util').KanjiFrequenciesMap1} */
|
||||
const map1 = new Map();
|
||||
/** @type {Map<string, string>} */
|
||||
const aliasMap = new Map();
|
||||
for (const {dictionary, dictionaryAlias, character, frequency, displayValue} of sourceFrequencies) {
|
||||
let map2 = map1.get(dictionary);
|
||||
if (typeof map2 === 'undefined') {
|
||||
map2 = new Map();
|
||||
map1.set(dictionary, map2);
|
||||
aliasMap.set(dictionary, dictionaryAlias);
|
||||
}
|
||||
|
||||
let frequencyData = map2.get(character);
|
||||
if (typeof frequencyData === 'undefined') {
|
||||
frequencyData = {character, values: new Map()};
|
||||
map2.set(character, frequencyData);
|
||||
}
|
||||
|
||||
frequencyData.values.set(createMapKey([frequency, displayValue]), {frequency, displayValue});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const [dictionary, map2] of map1.entries()) {
|
||||
const frequencies = [];
|
||||
const dictionaryAlias = aliasMap.get(dictionary) ?? dictionary;
|
||||
for (const {character, values} of map2.values()) {
|
||||
frequencies.push({
|
||||
character,
|
||||
values: [...values.values()],
|
||||
});
|
||||
}
|
||||
const currentDictionaryInfo = dictionaryInfo.find(({title}) => title === dictionary);
|
||||
const freqCount = currentDictionaryInfo?.counts?.kanjiMeta.freq ?? 0;
|
||||
results.push({dictionary, frequencies, dictionaryAlias, freqCount});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
|
||||
* @returns {import('dictionary-data-util').DictionaryGroupedPronunciations[]}
|
||||
*/
|
||||
export function getGroupedPronunciations(dictionaryEntry) {
|
||||
const {headwords, pronunciations: termPronunciations} = dictionaryEntry;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const allTerms = new Set();
|
||||
const allReadings = new Set();
|
||||
/** @type {Map<string, string>} */
|
||||
const aliasMap = new Map();
|
||||
for (const {term, reading} of headwords) {
|
||||
allTerms.add(term);
|
||||
allReadings.add(reading);
|
||||
}
|
||||
|
||||
/** @type {Map<string, import('dictionary-data-util').GroupedPronunciationInternal[]>} */
|
||||
const groupedPronunciationsMap = new Map();
|
||||
for (const {headwordIndex, dictionary, dictionaryAlias, pronunciations} of termPronunciations) {
|
||||
const {term, reading} = headwords[headwordIndex];
|
||||
let dictionaryGroupedPronunciationList = groupedPronunciationsMap.get(dictionary);
|
||||
if (typeof dictionaryGroupedPronunciationList === 'undefined') {
|
||||
dictionaryGroupedPronunciationList = [];
|
||||
groupedPronunciationsMap.set(dictionary, dictionaryGroupedPronunciationList);
|
||||
aliasMap.set(dictionary, dictionaryAlias);
|
||||
}
|
||||
for (const pronunciation of pronunciations) {
|
||||
let groupedPronunciation = findExistingGroupedPronunciation(reading, pronunciation, dictionaryGroupedPronunciationList);
|
||||
if (groupedPronunciation === null) {
|
||||
groupedPronunciation = {
|
||||
pronunciation,
|
||||
terms: new Set(),
|
||||
reading,
|
||||
};
|
||||
dictionaryGroupedPronunciationList.push(groupedPronunciation);
|
||||
}
|
||||
groupedPronunciation.terms.add(term);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('dictionary-data-util').DictionaryGroupedPronunciations[]} */
|
||||
const results2 = [];
|
||||
const multipleReadings = (allReadings.size > 1);
|
||||
for (const [dictionary, dictionaryGroupedPronunciationList] of groupedPronunciationsMap.entries()) {
|
||||
/** @type {import('dictionary-data-util').GroupedPronunciation[]} */
|
||||
const pronunciations2 = [];
|
||||
const dictionaryAlias = aliasMap.get(dictionary) ?? dictionary;
|
||||
for (const groupedPronunciation of dictionaryGroupedPronunciationList) {
|
||||
const {pronunciation, terms, reading} = groupedPronunciation;
|
||||
const exclusiveTerms = !areSetsEqual(terms, allTerms) ? getSetIntersection(terms, allTerms) : [];
|
||||
const exclusiveReadings = [];
|
||||
if (multipleReadings) {
|
||||
exclusiveReadings.push(reading);
|
||||
}
|
||||
pronunciations2.push({
|
||||
pronunciation,
|
||||
terms: [...terms],
|
||||
reading,
|
||||
exclusiveTerms,
|
||||
exclusiveReadings,
|
||||
});
|
||||
}
|
||||
|
||||
results2.push({dictionary, dictionaryAlias, pronunciations: pronunciations2});
|
||||
}
|
||||
return results2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('dictionary').PronunciationType} T
|
||||
* @param {import('dictionary').Pronunciation[]} pronunciations
|
||||
* @param {T} type
|
||||
* @returns {import('dictionary').PronunciationGeneric<T>[]}
|
||||
*/
|
||||
export function getPronunciationsOfType(pronunciations, type) {
|
||||
/** @type {import('dictionary').PronunciationGeneric<T>[]} */
|
||||
const results = [];
|
||||
for (const pronunciation of pronunciations) {
|
||||
if (pronunciation.type !== type) { continue; }
|
||||
// This is type safe, but for some reason the cast is needed.
|
||||
results.push(/** @type {import('dictionary').PronunciationGeneric<T>} */ (pronunciation));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').Tag[]|import('anki-templates').Tag[]} termTags
|
||||
* @returns {import('dictionary-data-util').TermFrequencyType}
|
||||
*/
|
||||
export function getTermFrequency(termTags) {
|
||||
let totalScore = 0;
|
||||
for (const {score} of termTags) {
|
||||
totalScore += score;
|
||||
}
|
||||
if (totalScore > 0) {
|
||||
return 'popular';
|
||||
} else if (totalScore < 0) {
|
||||
return 'rare';
|
||||
} else {
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').TermHeadword[]} headwords
|
||||
* @param {number[]} headwordIndices
|
||||
* @param {Set<string>} allTermsSet
|
||||
* @param {Set<string>} allReadingsSet
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getDisambiguations(headwords, headwordIndices, allTermsSet, allReadingsSet) {
|
||||
if (allTermsSet.size <= 1 && allReadingsSet.size <= 1) { return []; }
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const terms = new Set();
|
||||
/** @type {Set<string>} */
|
||||
const readings = new Set();
|
||||
for (const headwordIndex of headwordIndices) {
|
||||
const {term, reading} = headwords[headwordIndex];
|
||||
terms.add(term);
|
||||
readings.add(reading);
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const disambiguations = [];
|
||||
const addTerms = !areSetsEqual(terms, allTermsSet);
|
||||
const addReadings = !areSetsEqual(readings, allReadingsSet);
|
||||
if (addTerms) {
|
||||
disambiguations.push(...getSetIntersection(terms, allTermsSet));
|
||||
}
|
||||
if (addReadings) {
|
||||
if (addTerms) {
|
||||
for (const term of terms) {
|
||||
readings.delete(term);
|
||||
}
|
||||
}
|
||||
disambiguations.push(...getSetIntersection(readings, allReadingsSet));
|
||||
}
|
||||
return disambiguations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} wordClasses
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isNonNounVerbOrAdjective(wordClasses) {
|
||||
let isVerbOrAdjective = false;
|
||||
let isSuruVerb = false;
|
||||
let isNoun = false;
|
||||
for (const wordClass of wordClasses) {
|
||||
switch (wordClass) {
|
||||
case 'v1':
|
||||
case 'v5':
|
||||
case 'vk':
|
||||
case 'vz':
|
||||
case 'adj-i':
|
||||
isVerbOrAdjective = true;
|
||||
break;
|
||||
case 'vs':
|
||||
isVerbOrAdjective = true;
|
||||
isSuruVerb = true;
|
||||
break;
|
||||
case 'n':
|
||||
isNoun = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return isVerbOrAdjective && !(isSuruVerb && isNoun);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} current
|
||||
* @param {string} latest
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function compareRevisions(current, latest) {
|
||||
const simpleVersionTest = /^(\d+\.)*\d+$/; // dot-separated integers, so 4.7 or 24.1.1.1 are ok, 1.0.0-alpha is not
|
||||
if (!simpleVersionTest.test(current) || !simpleVersionTest.test(latest)) {
|
||||
return current < latest;
|
||||
}
|
||||
|
||||
const currentParts = current.split('.').map((part) => Number.parseInt(part, 10));
|
||||
const latestParts = latest.split('.').map((part) => Number.parseInt(part, 10));
|
||||
|
||||
if (currentParts.length !== latestParts.length) {
|
||||
return current < latest;
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentParts.length; i++) {
|
||||
if (currentParts[i] !== latestParts[i]) {
|
||||
return currentParts[i] < latestParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {string} reading
|
||||
* @param {import('dictionary').Pronunciation} pronunciation
|
||||
* @param {import('dictionary-data-util').GroupedPronunciationInternal[]} groupedPronunciationList
|
||||
* @returns {?import('dictionary-data-util').GroupedPronunciationInternal}
|
||||
*/
|
||||
function findExistingGroupedPronunciation(reading, pronunciation, groupedPronunciationList) {
|
||||
const existingGroupedPronunciation = groupedPronunciationList.find((groupedPronunciation) => {
|
||||
return groupedPronunciation.reading === reading && arePronunciationsEquivalent(groupedPronunciation, pronunciation);
|
||||
});
|
||||
|
||||
return existingGroupedPronunciation || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-data-util').GroupedPronunciationInternal} groupedPronunciation
|
||||
* @param {import('dictionary').Pronunciation} pronunciation2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function arePronunciationsEquivalent({pronunciation: pronunciation1}, pronunciation2) {
|
||||
if (
|
||||
pronunciation1.type !== pronunciation2.type ||
|
||||
!areTagListsEqual(pronunciation1.tags, pronunciation2.tags)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
switch (pronunciation1.type) {
|
||||
case 'pitch-accent':
|
||||
{
|
||||
// This cast is valid based on the type check at the start of the function.
|
||||
const pitchAccent2 = /** @type {import('dictionary').PitchAccent} */ (pronunciation2);
|
||||
return (
|
||||
pronunciation1.positions === pitchAccent2.positions &&
|
||||
areArraysEqual(pronunciation1.nasalPositions, pitchAccent2.nasalPositions) &&
|
||||
areArraysEqual(pronunciation1.devoicePositions, pitchAccent2.devoicePositions)
|
||||
);
|
||||
}
|
||||
case 'phonetic-transcription':
|
||||
{
|
||||
// This cast is valid based on the type check at the start of the function.
|
||||
const phoneticTranscription2 = /** @type {import('dictionary').PhoneticTranscription} */ (pronunciation2);
|
||||
return pronunciation1.ipa === phoneticTranscription2.ipa;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {T[]} array1
|
||||
* @param {T[]} array2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function areArraysEqual(array1, array2) {
|
||||
const ii = array1.length;
|
||||
if (ii !== array2.length) { return false; }
|
||||
for (let i = 0; i < ii; ++i) {
|
||||
if (array1[i] !== array2[i]) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary').Tag[]} tagList1
|
||||
* @param {import('dictionary').Tag[]} tagList2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function areTagListsEqual(tagList1, tagList2) {
|
||||
const ii = tagList1.length;
|
||||
if (tagList2.length !== ii) { return false; }
|
||||
|
||||
for (let i = 0; i < ii; ++i) {
|
||||
const tag1 = tagList1[i];
|
||||
const tag2 = tagList2[i];
|
||||
if (tag1.name !== tag2.name || !areArraysEqual(tag1.dictionaries, tag2.dictionaries)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {Set<T>} set1
|
||||
* @param {Set<T>} set2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function areSetsEqual(set1, set2) {
|
||||
if (set1.size !== set2.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const value of set1) {
|
||||
if (!set2.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {Set<T>} set1
|
||||
* @param {Set<T>} set2
|
||||
* @returns {T[]}
|
||||
*/
|
||||
function getSetIntersection(set1, set2) {
|
||||
const result = [];
|
||||
for (const value of set1) {
|
||||
if (set2.has(value)) {
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[]} array
|
||||
* @returns {string}
|
||||
*/
|
||||
function createMapKey(array) {
|
||||
return JSON.stringify(array);
|
||||
}
|
||||
60
vendor/yomitan/js/dictionary/dictionary-database-worker-handler.js
vendored
Normal file
60
vendor/yomitan/js/dictionary/dictionary-database-worker-handler.js
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 {ExtensionError} from '../core/extension-error.js';
|
||||
import {log} from '../core/log.js';
|
||||
import {DictionaryDatabase} from './dictionary-database.js';
|
||||
|
||||
export class DictionaryDatabaseWorkerHandler {
|
||||
constructor() {
|
||||
/** @type {DictionaryDatabase?} */
|
||||
this._dictionaryDatabase = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async prepare() {
|
||||
this._dictionaryDatabase = new DictionaryDatabase();
|
||||
try {
|
||||
await this._dictionaryDatabase.prepare();
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
self.addEventListener('message', this._onMessage.bind(this), false);
|
||||
self.addEventListener('messageerror', (event) => {
|
||||
const error = new ExtensionError('DictionaryDatabaseWorkerHandler: Error receiving message from main thread');
|
||||
error.data = event;
|
||||
log.error(error);
|
||||
});
|
||||
}
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<import('dictionary-database-worker-handler').MessageToWorker>} event
|
||||
*/
|
||||
_onMessage(event) {
|
||||
const {action} = event.data;
|
||||
switch (action) {
|
||||
case 'connectToDatabaseWorker':
|
||||
void this._dictionaryDatabase?.connectToDatabaseWorker(event.ports[0]);
|
||||
break;
|
||||
default:
|
||||
log.error(`Unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
vendor/yomitan/js/dictionary/dictionary-database-worker-main.js
vendored
Normal file
31
vendor/yomitan/js/dictionary/dictionary-database-worker-main.js
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 {log} from '../core/log.js';
|
||||
import {DictionaryDatabaseWorkerHandler} from './dictionary-database-worker-handler.js';
|
||||
|
||||
/** Entry point. */
|
||||
function main() {
|
||||
try {
|
||||
const dictionaryDatabaseWorkerHandler = new DictionaryDatabaseWorkerHandler();
|
||||
void dictionaryDatabaseWorkerHandler.prepare();
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
883
vendor/yomitan/js/dictionary/dictionary-database.js
vendored
Normal file
883
vendor/yomitan/js/dictionary/dictionary-database.js
vendored
Normal file
@@ -0,0 +1,883 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2016-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 {initWasm, Resvg} from '../../lib/resvg-wasm.js';
|
||||
import {createApiMap, invokeApiMapHandler} from '../core/api-map.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
import {log} from '../core/log.js';
|
||||
import {safePerformance} from '../core/safe-performance.js';
|
||||
import {stringReverse} from '../core/utilities.js';
|
||||
import {Database} from '../data/database.js';
|
||||
|
||||
export class DictionaryDatabase {
|
||||
constructor() {
|
||||
/** @type {Database<import('dictionary-database').ObjectStoreName>} */
|
||||
this._db = new Database();
|
||||
/** @type {string} */
|
||||
this._dbName = 'dict';
|
||||
/** @type {import('dictionary-database').CreateQuery<string>} */
|
||||
this._createOnlyQuery1 = (item) => IDBKeyRange.only(item);
|
||||
/** @type {import('dictionary-database').CreateQuery<import('dictionary-database').DictionaryAndQueryRequest>} */
|
||||
this._createOnlyQuery2 = (item) => IDBKeyRange.only(item.query);
|
||||
/** @type {import('dictionary-database').CreateQuery<import('dictionary-database').TermExactRequest>} */
|
||||
this._createOnlyQuery3 = (item) => IDBKeyRange.only(item.term);
|
||||
/** @type {import('dictionary-database').CreateQuery<import('dictionary-database').MediaRequest>} */
|
||||
this._createOnlyQuery4 = (item) => IDBKeyRange.only(item.path);
|
||||
/** @type {import('dictionary-database').CreateQuery<import('dictionary-database').DrawMediaGroupedRequest>} */
|
||||
this._createOnlyQuery5 = (item) => IDBKeyRange.only(item.path);
|
||||
/** @type {import('dictionary-database').CreateQuery<string>} */
|
||||
this._createBoundQuery1 = (item) => IDBKeyRange.bound(item, `${item}\uffff`, false, false);
|
||||
/** @type {import('dictionary-database').CreateQuery<string>} */
|
||||
this._createBoundQuery2 = (item) => {
|
||||
item = stringReverse(item);
|
||||
return IDBKeyRange.bound(item, `${item}\uffff`, false, false);
|
||||
};
|
||||
/** @type {import('dictionary-database').CreateResult<import('dictionary-database').TermExactRequest, import('dictionary-database').DatabaseTermEntryWithId, import('dictionary-database').TermEntry>} */
|
||||
this._createTermBind1 = this._createTermExact.bind(this);
|
||||
/** @type {import('dictionary-database').CreateResult<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').DatabaseTermEntryWithId, import('dictionary-database').TermEntry>} */
|
||||
this._createTermBind2 = this._createTermSequenceExact.bind(this);
|
||||
/** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseTermMeta, import('dictionary-database').TermMeta>} */
|
||||
this._createTermMetaBind = this._createTermMeta.bind(this);
|
||||
/** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseKanjiEntry, import('dictionary-database').KanjiEntry>} */
|
||||
this._createKanjiBind = this._createKanji.bind(this);
|
||||
/** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseKanjiMeta, import('dictionary-database').KanjiMeta>} */
|
||||
this._createKanjiMetaBind = this._createKanjiMeta.bind(this);
|
||||
/** @type {import('dictionary-database').CreateResult<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent, import('dictionary-database').Media>} */
|
||||
this._createMediaBind = this._createMedia.bind(this);
|
||||
/** @type {import('dictionary-database').CreateResult<import('dictionary-database').DrawMediaGroupedRequest, import('dictionary-database').MediaDataArrayBufferContent, import('dictionary-database').DrawMedia>} */
|
||||
this._createDrawMediaBind = this._createDrawMedia.bind(this);
|
||||
|
||||
/**
|
||||
* @type {Worker?}
|
||||
*/
|
||||
this._worker = null;
|
||||
|
||||
/**
|
||||
* @type {Uint8Array?}
|
||||
*/
|
||||
this._resvgFontBuffer = null;
|
||||
|
||||
/** @type {import('dictionary-database').ApiMap} */
|
||||
this._apiMap = createApiMap([
|
||||
['drawMedia', this._onDrawMedia.bind(this)],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* do upgrades for the IndexedDB schema (basically limited to adding new stores when needed)
|
||||
*/
|
||||
async prepare() {
|
||||
// do not do upgrades in web workers as they are considered to be children of the main thread and are not responsible for database upgrades
|
||||
const isWorker = self.constructor.name !== 'Window';
|
||||
const upgrade =
|
||||
/** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>[]?} */
|
||||
([
|
||||
/** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>} */
|
||||
({
|
||||
version: 20,
|
||||
stores: {
|
||||
terms: {
|
||||
primaryKey: {keyPath: 'id', autoIncrement: true},
|
||||
indices: ['dictionary', 'expression', 'reading'],
|
||||
},
|
||||
kanji: {
|
||||
primaryKey: {autoIncrement: true},
|
||||
indices: ['dictionary', 'character'],
|
||||
},
|
||||
tagMeta: {
|
||||
primaryKey: {autoIncrement: true},
|
||||
indices: ['dictionary'],
|
||||
},
|
||||
dictionaries: {
|
||||
primaryKey: {autoIncrement: true},
|
||||
indices: ['title', 'version'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
version: 30,
|
||||
stores: {
|
||||
termMeta: {
|
||||
primaryKey: {autoIncrement: true},
|
||||
indices: ['dictionary', 'expression'],
|
||||
},
|
||||
kanjiMeta: {
|
||||
primaryKey: {autoIncrement: true},
|
||||
indices: ['dictionary', 'character'],
|
||||
},
|
||||
tagMeta: {
|
||||
primaryKey: {autoIncrement: true},
|
||||
indices: ['dictionary', 'name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 40,
|
||||
stores: {
|
||||
terms: {
|
||||
primaryKey: {keyPath: 'id', autoIncrement: true},
|
||||
indices: ['dictionary', 'expression', 'reading', 'sequence'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 50,
|
||||
stores: {
|
||||
terms: {
|
||||
primaryKey: {keyPath: 'id', autoIncrement: true},
|
||||
indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 60,
|
||||
stores: {
|
||||
media: {
|
||||
primaryKey: {keyPath: 'id', autoIncrement: true},
|
||||
indices: ['dictionary', 'path'],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
await this._db.open(
|
||||
this._dbName,
|
||||
60,
|
||||
isWorker ? null : upgrade,
|
||||
);
|
||||
|
||||
// when we are not a worker ourselves, create a worker which is basically just a wrapper around this class, which we can use to offload some functions to
|
||||
if (!isWorker) {
|
||||
this._worker = new Worker('/js/dictionary/dictionary-database-worker-main.js', {type: 'module'});
|
||||
this._worker.addEventListener('error', (event) => {
|
||||
log.log('Worker terminated with error:', event);
|
||||
});
|
||||
this._worker.addEventListener('unhandledrejection', (event) => {
|
||||
log.log('Unhandled promise rejection in worker:', event);
|
||||
});
|
||||
} else {
|
||||
// when we are the worker, prepare to need to do some SVG work and load appropriate wasm & fonts
|
||||
await initWasm(fetch('/lib/resvg.wasm'));
|
||||
|
||||
const font = await fetch('/fonts/NotoSansJP-Regular.ttf');
|
||||
const fontData = await font.arrayBuffer();
|
||||
this._resvgFontBuffer = new Uint8Array(fontData);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async close() {
|
||||
this._db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPrepared() {
|
||||
return this._db.isOpen();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async purge() {
|
||||
if (this._db.isOpening()) {
|
||||
throw new Error('Cannot purge database while opening');
|
||||
}
|
||||
if (this._db.isOpen()) {
|
||||
this._db.close();
|
||||
}
|
||||
if (this._worker !== null) {
|
||||
this._worker.terminate();
|
||||
this._worker = null;
|
||||
}
|
||||
let result = false;
|
||||
try {
|
||||
await Database.deleteDatabase(this._dbName);
|
||||
result = true;
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
await this.prepare();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dictionaryName
|
||||
* @param {number} progressRate
|
||||
* @param {import('dictionary-database').DeleteDictionaryProgressCallback} onProgress
|
||||
*/
|
||||
async deleteDictionary(dictionaryName, progressRate, onProgress) {
|
||||
/** @type {[objectStoreName: import('dictionary-database').ObjectStoreName, key: string][][]} */
|
||||
const targetGroups = [
|
||||
[
|
||||
['kanji', 'dictionary'],
|
||||
['kanjiMeta', 'dictionary'],
|
||||
['terms', 'dictionary'],
|
||||
['termMeta', 'dictionary'],
|
||||
['tagMeta', 'dictionary'],
|
||||
['media', 'dictionary'],
|
||||
],
|
||||
[
|
||||
['dictionaries', 'title'],
|
||||
],
|
||||
];
|
||||
|
||||
let storeCount = 0;
|
||||
for (const targets of targetGroups) {
|
||||
storeCount += targets.length;
|
||||
}
|
||||
|
||||
/** @type {import('dictionary-database').DeleteDictionaryProgressData} */
|
||||
const progressData = {
|
||||
count: 0,
|
||||
processed: 0,
|
||||
storeCount,
|
||||
storesProcesed: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {IDBValidKey[]} keys
|
||||
* @returns {IDBValidKey[]}
|
||||
*/
|
||||
const filterKeys = (keys) => {
|
||||
++progressData.storesProcesed;
|
||||
progressData.count += keys.length;
|
||||
onProgress(progressData);
|
||||
return keys;
|
||||
};
|
||||
const onProgressWrapper = () => {
|
||||
const processed = progressData.processed + 1;
|
||||
progressData.processed = processed;
|
||||
if ((processed % progressRate) === 0 || processed === progressData.count) {
|
||||
onProgress(progressData);
|
||||
}
|
||||
};
|
||||
|
||||
for (const targets of targetGroups) {
|
||||
const promises = [];
|
||||
for (const [objectStoreName, indexName] of targets) {
|
||||
const query = IDBKeyRange.only(dictionaryName);
|
||||
const promise = this._db.bulkDelete(objectStoreName, indexName, query, filterKeys, onProgressWrapper);
|
||||
promises.push(promise);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} termList
|
||||
* @param {import('dictionary-database').DictionarySet} dictionaries
|
||||
* @param {import('dictionary-database').MatchType} matchType
|
||||
* @returns {Promise<import('dictionary-database').TermEntry[]>}
|
||||
*/
|
||||
findTermsBulk(termList, dictionaries, matchType) {
|
||||
const visited = new Set();
|
||||
/** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseTermEntryWithId>} */
|
||||
const predicate = (row) => {
|
||||
if (!dictionaries.has(row.dictionary)) { return false; }
|
||||
const {id} = row;
|
||||
if (visited.has(id)) { return false; }
|
||||
visited.add(id);
|
||||
return true;
|
||||
};
|
||||
|
||||
const indexNames = (matchType === 'suffix') ? ['expressionReverse', 'readingReverse'] : ['expression', 'reading'];
|
||||
|
||||
let createQuery = this._createOnlyQuery1;
|
||||
switch (matchType) {
|
||||
case 'prefix':
|
||||
createQuery = this._createBoundQuery1;
|
||||
break;
|
||||
case 'suffix':
|
||||
createQuery = this._createBoundQuery2;
|
||||
break;
|
||||
}
|
||||
|
||||
const createResult = this._createTermGeneric.bind(this, matchType);
|
||||
|
||||
return this._findMultiBulk('terms', indexNames, termList, createQuery, predicate, createResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').TermExactRequest[]} termList
|
||||
* @param {import('dictionary-database').DictionarySet} dictionaries
|
||||
* @returns {Promise<import('dictionary-database').TermEntry[]>}
|
||||
*/
|
||||
findTermsExactBulk(termList, dictionaries) {
|
||||
/** @type {import('dictionary-database').FindPredicate<import('dictionary-database').TermExactRequest, import('dictionary-database').DatabaseTermEntry>} */
|
||||
const predicate = (row, item) => (row.reading === item.reading && dictionaries.has(row.dictionary));
|
||||
return this._findMultiBulk('terms', ['expression'], termList, this._createOnlyQuery3, predicate, this._createTermBind1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DictionaryAndQueryRequest[]} items
|
||||
* @returns {Promise<import('dictionary-database').TermEntry[]>}
|
||||
*/
|
||||
findTermsBySequenceBulk(items) {
|
||||
/** @type {import('dictionary-database').FindPredicate<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').DatabaseTermEntry>} */
|
||||
const predicate = (row, item) => (row.dictionary === item.dictionary);
|
||||
return this._findMultiBulk('terms', ['sequence'], items, this._createOnlyQuery2, predicate, this._createTermBind2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} termList
|
||||
* @param {import('dictionary-database').DictionarySet} dictionaries
|
||||
* @returns {Promise<import('dictionary-database').TermMeta[]>}
|
||||
*/
|
||||
findTermMetaBulk(termList, dictionaries) {
|
||||
/** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseTermMeta>} */
|
||||
const predicate = (row) => dictionaries.has(row.dictionary);
|
||||
return this._findMultiBulk('termMeta', ['expression'], termList, this._createOnlyQuery1, predicate, this._createTermMetaBind);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} kanjiList
|
||||
* @param {import('dictionary-database').DictionarySet} dictionaries
|
||||
* @returns {Promise<import('dictionary-database').KanjiEntry[]>}
|
||||
*/
|
||||
findKanjiBulk(kanjiList, dictionaries) {
|
||||
/** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseKanjiEntry>} */
|
||||
const predicate = (row) => dictionaries.has(row.dictionary);
|
||||
return this._findMultiBulk('kanji', ['character'], kanjiList, this._createOnlyQuery1, predicate, this._createKanjiBind);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} kanjiList
|
||||
* @param {import('dictionary-database').DictionarySet} dictionaries
|
||||
* @returns {Promise<import('dictionary-database').KanjiMeta[]>}
|
||||
*/
|
||||
findKanjiMetaBulk(kanjiList, dictionaries) {
|
||||
/** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseKanjiMeta>} */
|
||||
const predicate = (row) => dictionaries.has(row.dictionary);
|
||||
return this._findMultiBulk('kanjiMeta', ['character'], kanjiList, this._createOnlyQuery1, predicate, this._createKanjiMetaBind);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DictionaryAndQueryRequest[]} items
|
||||
* @returns {Promise<(import('dictionary-database').Tag|undefined)[]>}
|
||||
*/
|
||||
findTagMetaBulk(items) {
|
||||
/** @type {import('dictionary-database').FindPredicate<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').Tag>} */
|
||||
const predicate = (row, item) => (row.dictionary === item.dictionary);
|
||||
return this._findFirstBulk('tagMeta', 'name', items, this._createOnlyQuery2, predicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} dictionary
|
||||
* @returns {Promise<?import('dictionary-database').Tag>}
|
||||
*/
|
||||
findTagForTitle(name, dictionary) {
|
||||
const query = IDBKeyRange.only(name);
|
||||
return this._db.find('tagMeta', 'name', query, (row) => (/** @type {import('dictionary-database').Tag} */ (row).dictionary === dictionary), null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').MediaRequest[]} items
|
||||
* @returns {Promise<import('dictionary-database').Media[]>}
|
||||
*/
|
||||
getMedia(items) {
|
||||
/** @type {import('dictionary-database').FindPredicate<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent>} */
|
||||
const predicate = (row, item) => (row.dictionary === item.dictionary);
|
||||
return this._findMultiBulk('media', ['path'], items, this._createOnlyQuery4, predicate, this._createMediaBind);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DrawMediaRequest[]} items
|
||||
* @param {MessagePort} source
|
||||
*/
|
||||
async drawMedia(items, source) {
|
||||
if (this._worker !== null) { // if a worker is available, offload the work to it
|
||||
this._worker.postMessage({action: 'drawMedia', params: {items}}, [source]);
|
||||
return;
|
||||
}
|
||||
// otherwise, you are the worker, so do the work
|
||||
safePerformance.mark('drawMedia:start');
|
||||
|
||||
// merge items with the same path to reduce the number of database queries. collects the canvases into a single array for each path.
|
||||
/** @type {Map<string, import('dictionary-database').DrawMediaGroupedRequest>} */
|
||||
const groupedItems = new Map();
|
||||
for (const item of items) {
|
||||
const {path, dictionary, canvasIndex, canvasWidth, canvasHeight, generation} = item;
|
||||
const key = `${path}:::${dictionary}`;
|
||||
if (!groupedItems.has(key)) {
|
||||
groupedItems.set(key, {path, dictionary, canvasIndexes: [], canvasWidth, canvasHeight, generation});
|
||||
}
|
||||
groupedItems.get(key)?.canvasIndexes.push(canvasIndex);
|
||||
}
|
||||
const groupedItemsArray = [...groupedItems.values()];
|
||||
|
||||
/** @type {import('dictionary-database').FindPredicate<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent>} */
|
||||
const predicate = (row, item) => (row.dictionary === item.dictionary);
|
||||
const results = await this._findMultiBulk('media', ['path'], groupedItemsArray, this._createOnlyQuery5, predicate, this._createDrawMediaBind);
|
||||
|
||||
// move all svgs to front to have a hotter loop
|
||||
results.sort((a, _b) => (a.mediaType === 'image/svg+xml' ? -1 : 1));
|
||||
|
||||
safePerformance.mark('drawMedia:draw:start');
|
||||
for (const m of results) {
|
||||
if (m.mediaType === 'image/svg+xml') {
|
||||
safePerformance.mark('drawMedia:draw:svg:start');
|
||||
/** @type {import('@resvg/resvg-wasm').ResvgRenderOptions} */
|
||||
const opts = {
|
||||
fitTo: {
|
||||
mode: 'width',
|
||||
value: m.canvasWidth,
|
||||
},
|
||||
font: {
|
||||
fontBuffers: this._resvgFontBuffer !== null ? [this._resvgFontBuffer] : [],
|
||||
},
|
||||
};
|
||||
const resvgJS = new Resvg(new Uint8Array(m.content), opts);
|
||||
const render = resvgJS.render();
|
||||
source.postMessage({action: 'drawBufferToCanvases', params: {buffer: render.pixels.buffer, width: render.width, height: render.height, canvasIndexes: m.canvasIndexes, generation: m.generation}}, [render.pixels.buffer]);
|
||||
safePerformance.mark('drawMedia:draw:svg:end');
|
||||
safePerformance.measure('drawMedia:draw:svg', 'drawMedia:draw:svg:start', 'drawMedia:draw:svg:end');
|
||||
} else {
|
||||
safePerformance.mark('drawMedia:draw:raster:start');
|
||||
|
||||
// ImageDecoder is slightly faster than Blob/createImageBitmap, but
|
||||
// 1) it is not available in Firefox <133
|
||||
// 2) it is available in Firefox >=133, but it's not possible to transfer VideoFrames cross-process
|
||||
//
|
||||
// So the second branch is a fallback for all versions of Firefox and doesn't use ImageDecoder at all
|
||||
// The second branch can eventually be changed to use ImageDecoder when we are okay with dropping support for Firefox <133
|
||||
// The branches can be unified entirely when Firefox implements support for transferring VideoFrames cross-process in postMessage
|
||||
if ('serviceWorker' in navigator) { // this is just a check for chrome, we don't actually use service worker functionality here
|
||||
const imageDecoder = new ImageDecoder({type: m.mediaType, data: m.content});
|
||||
await imageDecoder.decode().then((decodedImageResult) => {
|
||||
source.postMessage({action: 'drawDecodedImageToCanvases', params: {decodedImage: decodedImageResult.image, canvasIndexes: m.canvasIndexes, generation: m.generation}}, [decodedImageResult.image]);
|
||||
});
|
||||
} else {
|
||||
const image = new Blob([m.content], {type: m.mediaType});
|
||||
await createImageBitmap(image, {resizeWidth: m.canvasWidth, resizeHeight: m.canvasHeight, resizeQuality: 'high'}).then((decodedImage) => {
|
||||
// we need to do a dumb hack where we convert this ImageBitmap to an ImageData by drawing it to a temporary canvas, because Firefox doesn't support transferring ImageBitmaps cross-process
|
||||
const canvas = new OffscreenCanvas(decodedImage.width, decodedImage.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx !== null) {
|
||||
ctx.drawImage(decodedImage, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, decodedImage.width, decodedImage.height);
|
||||
source.postMessage({action: 'drawBufferToCanvases', params: {buffer: imageData.data.buffer, width: decodedImage.width, height: decodedImage.height, canvasIndexes: m.canvasIndexes, generation: m.generation}}, [imageData.data.buffer]);
|
||||
}
|
||||
});
|
||||
}
|
||||
safePerformance.mark('drawMedia:draw:raster:end');
|
||||
safePerformance.measure('drawMedia:draw:raster', 'drawMedia:draw:raster:start', 'drawMedia:draw:raster:end');
|
||||
}
|
||||
}
|
||||
safePerformance.mark('drawMedia:draw:end');
|
||||
safePerformance.measure('drawMedia:draw', 'drawMedia:draw:start', 'drawMedia:draw:end');
|
||||
|
||||
safePerformance.mark('drawMedia:end');
|
||||
safePerformance.measure('drawMedia', 'drawMedia:start', 'drawMedia:end');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import('dictionary-importer').Summary[]>}
|
||||
*/
|
||||
getDictionaryInfo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._db.transaction(['dictionaries'], 'readonly');
|
||||
const objectStore = transaction.objectStore('dictionaries');
|
||||
this._db.getAll(objectStore, null, resolve, reject, null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} dictionaryNames
|
||||
* @param {boolean} getTotal
|
||||
* @returns {Promise<import('dictionary-database').DictionaryCounts>}
|
||||
*/
|
||||
getDictionaryCounts(dictionaryNames, getTotal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const targets = [
|
||||
['kanji', 'dictionary'],
|
||||
['kanjiMeta', 'dictionary'],
|
||||
['terms', 'dictionary'],
|
||||
['termMeta', 'dictionary'],
|
||||
['tagMeta', 'dictionary'],
|
||||
['media', 'dictionary'],
|
||||
];
|
||||
const objectStoreNames = targets.map(([objectStoreName]) => objectStoreName);
|
||||
const transaction = this._db.transaction(objectStoreNames, 'readonly');
|
||||
const databaseTargets = targets.map(([objectStoreName, indexName]) => {
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
const index = objectStore.index(indexName);
|
||||
return {objectStore, index};
|
||||
});
|
||||
|
||||
/** @type {import('database').CountTarget[]} */
|
||||
const countTargets = [];
|
||||
if (getTotal) {
|
||||
for (const {objectStore} of databaseTargets) {
|
||||
countTargets.push([objectStore, void 0]);
|
||||
}
|
||||
}
|
||||
for (const dictionaryName of dictionaryNames) {
|
||||
const query = IDBKeyRange.only(dictionaryName);
|
||||
for (const {index} of databaseTargets) {
|
||||
countTargets.push([index, query]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} results
|
||||
*/
|
||||
const onCountComplete = (results) => {
|
||||
const resultCount = results.length;
|
||||
const targetCount = targets.length;
|
||||
/** @type {import('dictionary-database').DictionaryCountGroup[]} */
|
||||
const counts = [];
|
||||
for (let i = 0; i < resultCount; i += targetCount) {
|
||||
/** @type {import('dictionary-database').DictionaryCountGroup} */
|
||||
const countGroup = {};
|
||||
for (let j = 0; j < targetCount; ++j) {
|
||||
countGroup[targets[j][0]] = results[i + j];
|
||||
}
|
||||
counts.push(countGroup);
|
||||
}
|
||||
const total = getTotal ? /** @type {import('dictionary-database').DictionaryCountGroup} */ (counts.shift()) : null;
|
||||
resolve({total, counts});
|
||||
};
|
||||
|
||||
this._db.bulkCount(countTargets, onCountComplete, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async dictionaryExists(title) {
|
||||
const query = IDBKeyRange.only(title);
|
||||
const result = await this._db.find('dictionaries', 'title', query, null, null, void 0);
|
||||
return typeof result !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('dictionary-database').ObjectStoreName} T
|
||||
* @param {T} objectStoreName
|
||||
* @param {import('dictionary-database').ObjectStoreData<T>[]} items
|
||||
* @param {number} start
|
||||
* @param {number} count
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
bulkAdd(objectStoreName, items, start, count) {
|
||||
return this._db.bulkAdd(objectStoreName, items, start, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('dictionary-database').ObjectStoreName} T
|
||||
* @param {T} objectStoreName
|
||||
* @param {import('dictionary-database').ObjectStoreData<T>} item
|
||||
* @returns {Promise<IDBRequest<IDBValidKey>>}
|
||||
*/
|
||||
addWithResult(objectStoreName, item) {
|
||||
return this._db.addWithResult(objectStoreName, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('dictionary-database').ObjectStoreName} T
|
||||
* @param {T} objectStoreName
|
||||
* @param {import('dictionary-database').DatabaseUpdateItem[]} items
|
||||
* @param {number} start
|
||||
* @param {number} count
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
bulkUpdate(objectStoreName, items, start, count) {
|
||||
return this._db.bulkUpdate(objectStoreName, items, start, count);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @template [TRow=unknown]
|
||||
* @template [TItem=unknown]
|
||||
* @template [TResult=unknown]
|
||||
* @param {import('dictionary-database').ObjectStoreName} objectStoreName
|
||||
* @param {string[]} indexNames
|
||||
* @param {TItem[]} items
|
||||
* @param {import('dictionary-database').CreateQuery<TItem>} createQuery
|
||||
* @param {import('dictionary-database').FindPredicate<TItem, TRow>} predicate
|
||||
* @param {import('dictionary-database').CreateResult<TItem, TRow, TResult>} createResult
|
||||
* @returns {Promise<TResult[]>}
|
||||
*/
|
||||
_findMultiBulk(objectStoreName, indexNames, items, createQuery, predicate, createResult) {
|
||||
safePerformance.mark('findMultiBulk:start');
|
||||
return new Promise((resolve, reject) => {
|
||||
const itemCount = items.length;
|
||||
const indexCount = indexNames.length;
|
||||
/** @type {TResult[]} */
|
||||
const results = [];
|
||||
if (itemCount === 0 || indexCount === 0) {
|
||||
resolve(results);
|
||||
safePerformance.mark('findMultiBulk:end');
|
||||
safePerformance.measure('findMultiBulk', 'findMultiBulk:start', 'findMultiBulk:end');
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this._db.transaction([objectStoreName], 'readonly');
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
const indexList = [];
|
||||
for (const indexName of indexNames) {
|
||||
indexList.push(objectStore.index(indexName));
|
||||
}
|
||||
let completeCount = 0;
|
||||
const requiredCompleteCount = itemCount * indexCount;
|
||||
/**
|
||||
* @param {TItem} item
|
||||
* @returns {(rows: TRow[], data: import('dictionary-database').FindMultiBulkData<TItem>) => void}
|
||||
*/
|
||||
const onGetAll = (item) => (rows, data) => {
|
||||
if (typeof item === 'object' && item !== null && 'path' in item) {
|
||||
safePerformance.mark(`findMultiBulk:onGetAll:${item.path}:end`);
|
||||
safePerformance.measure(`findMultiBulk:onGetAll:${item.path}`, `findMultiBulk:onGetAll:${item.path}:start`, `findMultiBulk:onGetAll:${item.path}:end`);
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (predicate(row, data.item)) {
|
||||
results.push(createResult(row, data));
|
||||
}
|
||||
}
|
||||
if (++completeCount >= requiredCompleteCount) {
|
||||
resolve(results);
|
||||
safePerformance.mark('findMultiBulk:end');
|
||||
safePerformance.measure('findMultiBulk', 'findMultiBulk:start', 'findMultiBulk:end');
|
||||
}
|
||||
};
|
||||
safePerformance.mark('findMultiBulk:getAll:start');
|
||||
for (let i = 0; i < itemCount; ++i) {
|
||||
const item = items[i];
|
||||
const query = createQuery(item);
|
||||
for (let j = 0; j < indexCount; ++j) {
|
||||
/** @type {import('dictionary-database').FindMultiBulkData<TItem>} */
|
||||
const data = {item, itemIndex: i, indexIndex: j};
|
||||
if (typeof item === 'object' && item !== null && 'path' in item) {
|
||||
safePerformance.mark(`findMultiBulk:onGetAll:${item.path}:start`);
|
||||
}
|
||||
this._db.getAll(indexList[j], query, onGetAll(item), reject, data);
|
||||
}
|
||||
}
|
||||
safePerformance.mark('findMultiBulk:getAll:end');
|
||||
safePerformance.measure('findMultiBulk:getAll', 'findMultiBulk:getAll:start', 'findMultiBulk:getAll:end');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TRow=unknown]
|
||||
* @template [TItem=unknown]
|
||||
* @param {import('dictionary-database').ObjectStoreName} objectStoreName
|
||||
* @param {string} indexName
|
||||
* @param {TItem[]} items
|
||||
* @param {import('dictionary-database').CreateQuery<TItem>} createQuery
|
||||
* @param {import('dictionary-database').FindPredicate<TItem, TRow>} predicate
|
||||
* @returns {Promise<(TRow|undefined)[]>}
|
||||
*/
|
||||
_findFirstBulk(objectStoreName, indexName, items, createQuery, predicate) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const itemCount = items.length;
|
||||
/** @type {(TRow|undefined)[]} */
|
||||
const results = new Array(itemCount);
|
||||
if (itemCount === 0) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this._db.transaction([objectStoreName], 'readonly');
|
||||
const objectStore = transaction.objectStore(objectStoreName);
|
||||
const index = objectStore.index(indexName);
|
||||
let completeCount = 0;
|
||||
/**
|
||||
* @param {TRow|undefined} row
|
||||
* @param {number} itemIndex
|
||||
*/
|
||||
const onFind = (row, itemIndex) => {
|
||||
results[itemIndex] = row;
|
||||
if (++completeCount >= itemCount) {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < itemCount; ++i) {
|
||||
const item = items[i];
|
||||
const query = createQuery(item);
|
||||
this._db.findFirst(index, query, onFind, reject, i, predicate, item, void 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').MatchType} matchType
|
||||
* @param {import('dictionary-database').DatabaseTermEntryWithId} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<string>} data
|
||||
* @returns {import('dictionary-database').TermEntry}
|
||||
*/
|
||||
_createTermGeneric(matchType, row, data) {
|
||||
const matchSourceIsTerm = (data.indexIndex === 0);
|
||||
const matchSource = (matchSourceIsTerm ? 'term' : 'reading');
|
||||
if ((matchSourceIsTerm ? row.expression : row.reading) === data.item) {
|
||||
matchType = 'exact';
|
||||
}
|
||||
return this._createTerm(matchSource, matchType, row, data.itemIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DatabaseTermEntryWithId} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').TermExactRequest>} data
|
||||
* @returns {import('dictionary-database').TermEntry}
|
||||
*/
|
||||
_createTermExact(row, data) {
|
||||
return this._createTerm('term', 'exact', row, data.itemIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DatabaseTermEntryWithId} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').DictionaryAndQueryRequest>} data
|
||||
* @returns {import('dictionary-database').TermEntry}
|
||||
*/
|
||||
_createTermSequenceExact(row, data) {
|
||||
return this._createTerm('sequence', 'exact', row, data.itemIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').MatchSource} matchSource
|
||||
* @param {import('dictionary-database').MatchType} matchType
|
||||
* @param {import('dictionary-database').DatabaseTermEntryWithId} row
|
||||
* @param {number} index
|
||||
* @returns {import('dictionary-database').TermEntry}
|
||||
*/
|
||||
_createTerm(matchSource, matchType, row, index) {
|
||||
const {sequence} = row;
|
||||
return {
|
||||
index,
|
||||
matchType,
|
||||
matchSource,
|
||||
term: row.expression,
|
||||
reading: row.reading,
|
||||
definitionTags: this._splitField(row.definitionTags || row.tags),
|
||||
termTags: this._splitField(row.termTags),
|
||||
rules: this._splitField(row.rules),
|
||||
definitions: row.glossary,
|
||||
score: row.score,
|
||||
dictionary: row.dictionary,
|
||||
id: row.id,
|
||||
sequence: typeof sequence === 'number' ? sequence : -1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DatabaseKanjiEntry} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<string>} data
|
||||
* @returns {import('dictionary-database').KanjiEntry}
|
||||
*/
|
||||
_createKanji(row, {itemIndex: index}) {
|
||||
const {stats} = row;
|
||||
return {
|
||||
index,
|
||||
character: row.character,
|
||||
onyomi: this._splitField(row.onyomi),
|
||||
kunyomi: this._splitField(row.kunyomi),
|
||||
tags: this._splitField(row.tags),
|
||||
definitions: row.meanings,
|
||||
stats: typeof stats === 'object' && stats !== null ? stats : {},
|
||||
dictionary: row.dictionary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DatabaseTermMeta} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<string>} data
|
||||
* @returns {import('dictionary-database').TermMeta}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_createTermMeta({expression: term, mode, data, dictionary}, {itemIndex: index}) {
|
||||
switch (mode) {
|
||||
case 'freq':
|
||||
return {index, term, mode, data, dictionary};
|
||||
case 'pitch':
|
||||
return {index, term, mode, data, dictionary};
|
||||
case 'ipa':
|
||||
return {index, term, mode, data, dictionary};
|
||||
default:
|
||||
throw new Error(`Unknown mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').DatabaseKanjiMeta} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<string>} data
|
||||
* @returns {import('dictionary-database').KanjiMeta}
|
||||
*/
|
||||
_createKanjiMeta({character, mode, data, dictionary}, {itemIndex: index}) {
|
||||
return {index, character, mode, data, dictionary};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').MediaDataArrayBufferContent} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').MediaRequest>} data
|
||||
* @returns {import('dictionary-database').Media}
|
||||
*/
|
||||
_createMedia(row, {itemIndex: index}) {
|
||||
const {dictionary, path, mediaType, width, height, content} = row;
|
||||
return {index, dictionary, path, mediaType, width, height, content};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-database').MediaDataArrayBufferContent} row
|
||||
* @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').DrawMediaGroupedRequest>} data
|
||||
* @returns {import('dictionary-database').DrawMedia}
|
||||
*/
|
||||
_createDrawMedia(row, {itemIndex: index, item: {canvasIndexes, canvasWidth, canvasHeight, generation}}) {
|
||||
const {dictionary, path, mediaType, width, height, content} = row;
|
||||
return {index, dictionary, path, mediaType, width, height, content, canvasIndexes, canvasWidth, canvasHeight, generation};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} field
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_splitField(field) {
|
||||
return typeof field === 'string' && field.length > 0 ? field.split(' ') : [];
|
||||
}
|
||||
|
||||
// Parent-Worker API
|
||||
|
||||
/**
|
||||
* @param {MessagePort} port
|
||||
*/
|
||||
async connectToDatabaseWorker(port) {
|
||||
if (this._worker !== null) {
|
||||
// executes outside of worker
|
||||
this._worker.postMessage({action: 'connectToDatabaseWorker'}, [port]);
|
||||
return;
|
||||
}
|
||||
// executes inside worker
|
||||
port.onmessage = (/** @type {MessageEvent<import('dictionary-database').ApiMessageAny>} */event) => {
|
||||
const {action, params} = event.data;
|
||||
return invokeApiMapHandler(this._apiMap, action, params, [port], () => {});
|
||||
};
|
||||
port.onmessageerror = (event) => {
|
||||
const error = new ExtensionError('DictionaryDatabase: Error receiving message from main thread');
|
||||
error.data = event;
|
||||
log.error(error);
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {import('dictionary-database').ApiHandler<'drawMedia'>} */
|
||||
_onDrawMedia(params, port) {
|
||||
void this.drawMedia(params.requests, port);
|
||||
}
|
||||
}
|
||||
50
vendor/yomitan/js/dictionary/dictionary-importer-media-loader.js
vendored
Normal file
50
vendor/yomitan/js/dictionary/dictionary-importer-media-loader.js
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Class used for loading and validating media during the dictionary import process.
|
||||
*/
|
||||
export class DictionaryImporterMediaLoader {
|
||||
/** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */
|
||||
getImageDetails(content, mediaType, transfer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const eventListeners = new EventListenerCollection();
|
||||
const cleanup = () => {
|
||||
image.removeAttribute('src');
|
||||
URL.revokeObjectURL(url);
|
||||
eventListeners.removeAllEventListeners();
|
||||
};
|
||||
eventListeners.addEventListener(image, 'load', () => {
|
||||
const {naturalWidth: width, naturalHeight: height} = image;
|
||||
if (Array.isArray(transfer)) { transfer.push(content); }
|
||||
cleanup();
|
||||
resolve({content, width, height});
|
||||
}, false);
|
||||
eventListeners.addEventListener(image, 'error', () => {
|
||||
cleanup();
|
||||
reject(new Error('Image failed to load'));
|
||||
}, false);
|
||||
const blob = new Blob([content], {type: mediaType});
|
||||
const url = URL.createObjectURL(blob);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
1015
vendor/yomitan/js/dictionary/dictionary-importer.js
vendored
Normal file
1015
vendor/yomitan/js/dictionary/dictionary-importer.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
137
vendor/yomitan/js/dictionary/dictionary-worker-handler.js
vendored
Normal file
137
vendor/yomitan/js/dictionary/dictionary-worker-handler.js
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 {ExtensionError} from '../core/extension-error.js';
|
||||
import {DictionaryDatabase} from './dictionary-database.js';
|
||||
import {DictionaryImporter} from './dictionary-importer.js';
|
||||
import {DictionaryWorkerMediaLoader} from './dictionary-worker-media-loader.js';
|
||||
|
||||
export class DictionaryWorkerHandler {
|
||||
constructor() {
|
||||
/** @type {DictionaryWorkerMediaLoader} */
|
||||
this._mediaLoader = new DictionaryWorkerMediaLoader();
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
self.addEventListener('message', this._onMessage.bind(this), false);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<import('dictionary-worker-handler').Message>} event
|
||||
*/
|
||||
_onMessage(event) {
|
||||
const {action, params} = event.data;
|
||||
switch (action) {
|
||||
case 'importDictionary':
|
||||
void this._onMessageWithProgress(params, this._importDictionary.bind(this));
|
||||
break;
|
||||
case 'deleteDictionary':
|
||||
void this._onMessageWithProgress(params, this._deleteDictionary.bind(this));
|
||||
break;
|
||||
case 'getDictionaryCounts':
|
||||
void this._onMessageWithProgress(params, this._getDictionaryCounts.bind(this));
|
||||
break;
|
||||
case 'getImageDetails.response':
|
||||
this._mediaLoader.handleMessage(params);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @param {T} params
|
||||
* @param {(details: T, onProgress: import('dictionary-worker-handler').OnProgressCallback) => Promise<unknown>} handler
|
||||
*/
|
||||
async _onMessageWithProgress(params, handler) {
|
||||
/**
|
||||
* @param {...unknown} args
|
||||
*/
|
||||
const onProgress = (...args) => {
|
||||
self.postMessage({
|
||||
action: 'progress',
|
||||
params: {args},
|
||||
});
|
||||
};
|
||||
let response;
|
||||
try {
|
||||
const result = await handler(params, onProgress);
|
||||
response = {result};
|
||||
} catch (e) {
|
||||
response = {error: ExtensionError.serialize(e)};
|
||||
}
|
||||
self.postMessage({action: 'complete', params: response});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-worker-handler').ImportDictionaryMessageParams} details
|
||||
* @param {import('dictionary-worker-handler').OnProgressCallback} onProgress
|
||||
* @returns {Promise<import('dictionary-worker').MessageCompleteResultSerialized>}
|
||||
*/
|
||||
async _importDictionary({details, archiveContent}, onProgress) {
|
||||
const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
|
||||
try {
|
||||
const dictionaryImporter = new DictionaryImporter(this._mediaLoader, onProgress);
|
||||
const {result, errors} = await dictionaryImporter.importDictionary(dictionaryDatabase, archiveContent, details);
|
||||
return {
|
||||
result,
|
||||
errors: errors.map((error) => ExtensionError.serialize(error)),
|
||||
};
|
||||
} finally {
|
||||
void dictionaryDatabase.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-worker-handler').DeleteDictionaryMessageParams} details
|
||||
* @param {import('dictionary-database').DeleteDictionaryProgressCallback} onProgress
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _deleteDictionary({dictionaryTitle}, onProgress) {
|
||||
const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
|
||||
try {
|
||||
return await dictionaryDatabase.deleteDictionary(dictionaryTitle, 1000, onProgress);
|
||||
} finally {
|
||||
void dictionaryDatabase.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-worker-handler').GetDictionaryCountsMessageParams} details
|
||||
* @returns {Promise<import('dictionary-database').DictionaryCounts>}
|
||||
*/
|
||||
async _getDictionaryCounts({dictionaryNames, getTotal}) {
|
||||
const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
|
||||
try {
|
||||
return await dictionaryDatabase.getDictionaryCounts(dictionaryNames, getTotal);
|
||||
} finally {
|
||||
void dictionaryDatabase.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<DictionaryDatabase>}
|
||||
*/
|
||||
async _getPreparedDictionaryDatabase() {
|
||||
const dictionaryDatabase = new DictionaryDatabase();
|
||||
await dictionaryDatabase.prepare();
|
||||
return dictionaryDatabase;
|
||||
}
|
||||
}
|
||||
32
vendor/yomitan/js/dictionary/dictionary-worker-main.js
vendored
Normal file
32
vendor/yomitan/js/dictionary/dictionary-worker-main.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {log} from '../core/log.js';
|
||||
import {DictionaryWorkerHandler} from './dictionary-worker-handler.js';
|
||||
|
||||
/** Entry point. */
|
||||
function main() {
|
||||
try {
|
||||
const dictionaryWorkerHandler = new DictionaryWorkerHandler();
|
||||
dictionaryWorkerHandler.prepare();
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
64
vendor/yomitan/js/dictionary/dictionary-worker-media-loader.js
vendored
Normal file
64
vendor/yomitan/js/dictionary/dictionary-worker-media-loader.js
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 {generateId} from '../core/utilities.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
|
||||
/**
|
||||
* Class used for loading and validating media from a worker thread
|
||||
* during the dictionary import process.
|
||||
*/
|
||||
export class DictionaryWorkerMediaLoader {
|
||||
/**
|
||||
* Creates a new instance of the media loader.
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {Map<string, {resolve: (result: import('dictionary-worker-media-loader').ImageDetails) => void, reject: (reason?: import('core').RejectionReason) => void}>} */
|
||||
this._requests = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a response message posted to the worker thread.
|
||||
* @param {import('dictionary-worker-media-loader').HandleMessageParams} params Details of the response.
|
||||
*/
|
||||
handleMessage(params) {
|
||||
const {id} = params;
|
||||
const request = this._requests.get(id);
|
||||
if (typeof request === 'undefined') { return; }
|
||||
this._requests.delete(id);
|
||||
const {error} = params;
|
||||
if (typeof error !== 'undefined') {
|
||||
request.reject(ExtensionError.deserialize(error));
|
||||
} else {
|
||||
request.resolve(params.result);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */
|
||||
getImageDetails(content, mediaType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = generateId(16);
|
||||
this._requests.set(id, {resolve, reject});
|
||||
// This is executed in a Worker context, so the self needs to be force cast
|
||||
/** @type {Worker} */ (/** @type {unknown} */ (self)).postMessage({
|
||||
action: 'getImageDetails',
|
||||
params: {id, content, mediaType},
|
||||
}, [content]);
|
||||
});
|
||||
}
|
||||
}
|
||||
206
vendor/yomitan/js/dictionary/dictionary-worker.js
vendored
Normal file
206
vendor/yomitan/js/dictionary/dictionary-worker.js
vendored
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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 {ExtensionError} from '../core/extension-error.js';
|
||||
import {DictionaryImporterMediaLoader} from './dictionary-importer-media-loader.js';
|
||||
|
||||
export class DictionaryWorker {
|
||||
constructor() {
|
||||
/** @type {DictionaryImporterMediaLoader} */
|
||||
this._dictionaryImporterMediaLoader = new DictionaryImporterMediaLoader();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} archiveContent
|
||||
* @param {import('dictionary-importer').ImportDetails} details
|
||||
* @param {?import('dictionary-worker').ImportProgressCallback} onProgress
|
||||
* @returns {Promise<import('dictionary-importer').ImportResult>}
|
||||
*/
|
||||
importDictionary(archiveContent, details, onProgress) {
|
||||
return this._invoke(
|
||||
'importDictionary',
|
||||
{details, archiveContent},
|
||||
[archiveContent],
|
||||
onProgress,
|
||||
this._formatImportDictionaryResult.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dictionaryTitle
|
||||
* @param {?import('dictionary-worker').DeleteProgressCallback} onProgress
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteDictionary(dictionaryTitle, onProgress) {
|
||||
return this._invoke('deleteDictionary', {dictionaryTitle}, [], onProgress, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} dictionaryNames
|
||||
* @param {boolean} getTotal
|
||||
* @returns {Promise<import('dictionary-database').DictionaryCounts>}
|
||||
*/
|
||||
getDictionaryCounts(dictionaryNames, getTotal) {
|
||||
return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}, [], null, null);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @template [TParams=import('core').SerializableObject]
|
||||
* @template [TResponseRaw=unknown]
|
||||
* @template [TResponse=unknown]
|
||||
* @param {string} action
|
||||
* @param {TParams} params
|
||||
* @param {Transferable[]} transfer
|
||||
* @param {?(arg: import('core').SafeAny) => void} onProgress
|
||||
* @param {?(result: TResponseRaw) => TResponse} formatResult
|
||||
*/
|
||||
_invoke(action, params, transfer, onProgress, formatResult) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker('/js/dictionary/dictionary-worker-main.js', {type: 'module'});
|
||||
/** @type {import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>} */
|
||||
const details = {
|
||||
complete: false,
|
||||
worker,
|
||||
resolve,
|
||||
reject,
|
||||
onMessage: null,
|
||||
onProgress,
|
||||
formatResult,
|
||||
};
|
||||
// Ugly typecast below due to not being able to explicitly state the template types
|
||||
/** @type {(event: MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>) => void} */
|
||||
const onMessage = /** @type {(details: import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>, event: MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>) => void} */ (this._onMessage).bind(this, details);
|
||||
details.onMessage = onMessage;
|
||||
worker.addEventListener('message', onMessage);
|
||||
worker.postMessage({action, params}, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TResponseRaw=unknown]
|
||||
* @template [TResponse=unknown]
|
||||
* @param {import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>} details
|
||||
* @param {MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>} event
|
||||
*/
|
||||
_onMessage(details, event) {
|
||||
if (details.complete) { return; }
|
||||
const {action, params} = event.data;
|
||||
switch (action) {
|
||||
case 'complete':
|
||||
{
|
||||
const {worker, resolve, reject, onMessage, formatResult} = details;
|
||||
if (worker === null || onMessage === null || resolve === null || reject === null) { return; }
|
||||
details.complete = true;
|
||||
details.worker = null;
|
||||
details.resolve = null;
|
||||
details.reject = null;
|
||||
details.onMessage = null;
|
||||
details.onProgress = null;
|
||||
details.formatResult = null;
|
||||
worker.removeEventListener('message', onMessage);
|
||||
worker.terminate();
|
||||
this._onMessageComplete(params, resolve, reject, formatResult);
|
||||
}
|
||||
break;
|
||||
case 'progress':
|
||||
this._onMessageProgress(params, details.onProgress);
|
||||
break;
|
||||
case 'getImageDetails':
|
||||
{
|
||||
const {worker} = details;
|
||||
if (worker === null) { return; }
|
||||
void this._onMessageGetImageDetails(params, worker);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [TResponseRaw=unknown]
|
||||
* @template [TResponse=unknown]
|
||||
* @param {import('dictionary-worker').MessageCompleteParams<TResponseRaw>} params
|
||||
* @param {(result: TResponse) => void} resolve
|
||||
* @param {(reason?: import('core').RejectionReason) => void} reject
|
||||
* @param {?(result: TResponseRaw) => TResponse} formatResult
|
||||
*/
|
||||
_onMessageComplete(params, resolve, reject, formatResult) {
|
||||
const {error} = params;
|
||||
if (typeof error !== 'undefined') {
|
||||
reject(ExtensionError.deserialize(error));
|
||||
} else {
|
||||
const {result} = params;
|
||||
if (typeof formatResult === 'function') {
|
||||
let result2;
|
||||
try {
|
||||
result2 = formatResult(result);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
resolve(result2);
|
||||
} else {
|
||||
// If formatResult is not provided, the response is assumed to be the same type
|
||||
// For some reason, eslint thinks the TResponse type is undefined
|
||||
// eslint-disable-next-line jsdoc/no-undefined-types
|
||||
resolve(/** @type {TResponse} */ (/** @type {unknown} */ (result)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-worker').MessageProgressParams} params
|
||||
* @param {?(...args: unknown[]) => void} onProgress
|
||||
*/
|
||||
_onMessageProgress(params, onProgress) {
|
||||
if (typeof onProgress !== 'function') { return; }
|
||||
const {args} = params;
|
||||
onProgress(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-worker').MessageGetImageDetailsParams} params
|
||||
* @param {Worker} worker
|
||||
*/
|
||||
async _onMessageGetImageDetails(params, worker) {
|
||||
const {id, content, mediaType} = params;
|
||||
/** @type {Transferable[]} */
|
||||
const transfer = [];
|
||||
let response;
|
||||
try {
|
||||
const result = await this._dictionaryImporterMediaLoader.getImageDetails(content, mediaType, transfer);
|
||||
response = {id, result};
|
||||
} catch (e) {
|
||||
response = {id, error: ExtensionError.serialize(e)};
|
||||
}
|
||||
worker.postMessage({action: 'getImageDetails.response', params: response}, transfer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dictionary-worker').MessageCompleteResultSerialized} response
|
||||
* @returns {import('dictionary-worker').MessageCompleteResult}
|
||||
*/
|
||||
_formatImportDictionaryResult(response) {
|
||||
const {result, errors} = response;
|
||||
return {
|
||||
result,
|
||||
errors: errors.map((error) => ExtensionError.deserialize(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
1454
vendor/yomitan/js/display/display-anki.js
vendored
Normal file
1454
vendor/yomitan/js/display/display-anki.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1003
vendor/yomitan/js/display/display-audio.js
vendored
Normal file
1003
vendor/yomitan/js/display/display-audio.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
130
vendor/yomitan/js/display/display-content-manager.js
vendored
Normal file
130
vendor/yomitan/js/display/display-content-manager.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 {base64ToArrayBuffer} from '../data/array-buffer-util.js';
|
||||
|
||||
/**
|
||||
* The content manager which is used when generating HTML display content.
|
||||
*/
|
||||
export class DisplayContentManager {
|
||||
/**
|
||||
* Creates a new instance of the class.
|
||||
* @param {import('./display.js').Display} display The display instance that owns this object.
|
||||
*/
|
||||
constructor(display) {
|
||||
/** @type {import('./display.js').Display} */
|
||||
this._display = display;
|
||||
/** @type {import('core').TokenObject} */
|
||||
this._token = {};
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {import('display-content-manager').LoadMediaRequest[]} */
|
||||
this._loadMediaRequests = [];
|
||||
}
|
||||
|
||||
/** @type {import('display-content-manager').LoadMediaRequest[]} */
|
||||
get loadMediaRequests() {
|
||||
return this._loadMediaRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues loading media file from a given dictionary.
|
||||
* @param {string} path
|
||||
* @param {string} dictionary
|
||||
* @param {OffscreenCanvas} canvas
|
||||
*/
|
||||
loadMedia(path, dictionary, canvas) {
|
||||
this._loadMediaRequests.push({path, dictionary, canvas});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unloads all media that has been loaded.
|
||||
*/
|
||||
unloadAll() {
|
||||
this._token = {};
|
||||
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
|
||||
this._loadMediaRequests = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up attributes and events for a link element.
|
||||
* @param {HTMLAnchorElement} element The link element.
|
||||
* @param {string} href The URL.
|
||||
* @param {boolean} internal Whether or not the URL is an internal or external link.
|
||||
*/
|
||||
prepareLink(element, href, internal) {
|
||||
element.href = href;
|
||||
if (!internal) {
|
||||
element.target = '_blank';
|
||||
element.rel = 'noreferrer noopener';
|
||||
}
|
||||
this._eventListeners.addEventListener(element, 'click', this._onLinkClick.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute media requests
|
||||
*/
|
||||
async executeMediaRequests() {
|
||||
this._display.application.api.drawMedia(this._loadMediaRequests, this._loadMediaRequests.map(({canvas}) => canvas));
|
||||
this._loadMediaRequests = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {string} dictionary
|
||||
* @param {Window} window
|
||||
*/
|
||||
async openMediaInTab(path, dictionary, window) {
|
||||
const data = await this._display.application.api.getMedia([{path, dictionary}]);
|
||||
const buffer = base64ToArrayBuffer(data[0].content);
|
||||
const blob = new Blob([buffer], {type: data[0].mediaType});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl, '_blank')?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onLinkClick(e) {
|
||||
const {href} = /** @type {HTMLAnchorElement} */ (e.currentTarget);
|
||||
if (typeof href !== 'string') { return; }
|
||||
|
||||
const baseUrl = new URL(location.href);
|
||||
const url = new URL(href, baseUrl);
|
||||
const internal = (url.protocol === baseUrl.protocol && url.host === baseUrl.host);
|
||||
if (!internal) { return; }
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
/** @type {import('display').HistoryParams} */
|
||||
const params = {};
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
this._display.setContent({
|
||||
historyMode: 'new',
|
||||
focus: false,
|
||||
params,
|
||||
state: null,
|
||||
content: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
1168
vendor/yomitan/js/display/display-generator.js
vendored
Normal file
1168
vendor/yomitan/js/display/display-generator.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
254
vendor/yomitan/js/display/display-history.js
vendored
Normal file
254
vendor/yomitan/js/display/display-history.js
vendored
Normal file
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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 {isObjectNotArray} from '../core/object-utilities.js';
|
||||
import {generateId} from '../core/utilities.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('display-history').Events>
|
||||
*/
|
||||
export class DisplayHistory extends EventDispatcher {
|
||||
/**
|
||||
* @param {boolean} clearable
|
||||
* @param {boolean} useBrowserHistory
|
||||
*/
|
||||
constructor(clearable, useBrowserHistory) {
|
||||
super();
|
||||
/** @type {boolean} */
|
||||
this._clearable = clearable;
|
||||
/** @type {boolean} */
|
||||
this._useBrowserHistory = useBrowserHistory;
|
||||
/** @type {Map<string, import('display-history').Entry>} */
|
||||
this._historyMap = new Map();
|
||||
|
||||
/** @type {unknown} */
|
||||
const historyState = history.state;
|
||||
const {id, state} = (
|
||||
isObjectNotArray(historyState) ?
|
||||
historyState :
|
||||
{id: null, state: null}
|
||||
);
|
||||
/** @type {?import('display-history').EntryState} */
|
||||
const stateObject = isObjectNotArray(state) ? state : null;
|
||||
/** @type {import('display-history').Entry} */
|
||||
this._current = this._createHistoryEntry(id, location.href, stateObject, null, null);
|
||||
}
|
||||
|
||||
/** @type {?import('display-history').EntryState} */
|
||||
get state() {
|
||||
return this._current.state;
|
||||
}
|
||||
|
||||
/** @type {?import('display-history').EntryContent} */
|
||||
get content() {
|
||||
return this._current.content;
|
||||
}
|
||||
|
||||
/** @type {boolean} */
|
||||
get useBrowserHistory() {
|
||||
return this._useBrowserHistory;
|
||||
}
|
||||
|
||||
set useBrowserHistory(value) {
|
||||
this._useBrowserHistory = value;
|
||||
}
|
||||
|
||||
/** @type {boolean} */
|
||||
get clearable() { return this._clearable; }
|
||||
set clearable(value) { this._clearable = value; }
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
window.addEventListener('popstate', this._onPopState.bind(this), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasNext() {
|
||||
return this._current.next !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasPrevious() {
|
||||
return this._current.previous !== null;
|
||||
}
|
||||
|
||||
/** */
|
||||
clear() {
|
||||
if (!this._clearable) { return; }
|
||||
this._clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
back() {
|
||||
return this._go(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
forward() {
|
||||
return this._go(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?import('display-history').EntryState} state
|
||||
* @param {?import('display-history').EntryContent} content
|
||||
* @param {string} [url]
|
||||
*/
|
||||
pushState(state, content, url) {
|
||||
if (typeof url === 'undefined') { url = location.href; }
|
||||
|
||||
const entry = this._createHistoryEntry(null, url, state, content, this._current);
|
||||
this._current.next = entry;
|
||||
this._current = entry;
|
||||
this._updateHistoryFromCurrent(!this._useBrowserHistory);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?import('display-history').EntryState} state
|
||||
* @param {?import('display-history').EntryContent} content
|
||||
* @param {string} [url]
|
||||
*/
|
||||
replaceState(state, content, url) {
|
||||
if (typeof url === 'undefined') { url = location.href; }
|
||||
|
||||
this._current.url = url;
|
||||
this._current.state = state;
|
||||
this._current.content = content;
|
||||
this._updateHistoryFromCurrent(true);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onPopState() {
|
||||
this._updateStateFromHistory();
|
||||
this._triggerStateChanged(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} forward
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_go(forward) {
|
||||
if (this._useBrowserHistory) {
|
||||
if (forward) {
|
||||
history.forward();
|
||||
} else {
|
||||
history.back();
|
||||
}
|
||||
} else {
|
||||
const target = forward ? this._current.next : this._current.previous;
|
||||
if (target === null) { return false; }
|
||||
this._current = target;
|
||||
this._updateHistoryFromCurrent(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} synthetic
|
||||
*/
|
||||
_triggerStateChanged(synthetic) {
|
||||
this.trigger('stateChanged', {synthetic});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} replace
|
||||
*/
|
||||
_updateHistoryFromCurrent(replace) {
|
||||
const {id, state, url} = this._current;
|
||||
if (replace) {
|
||||
history.replaceState({id, state}, '', url);
|
||||
} else {
|
||||
history.pushState({id, state}, '', url);
|
||||
}
|
||||
this._triggerStateChanged(true);
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateStateFromHistory() {
|
||||
/** @type {unknown} */
|
||||
let state = history.state;
|
||||
let id = null;
|
||||
if (isObjectNotArray(state)) {
|
||||
id = state.id;
|
||||
if (typeof id === 'string') {
|
||||
const entry = this._historyMap.get(id);
|
||||
if (typeof entry !== 'undefined') {
|
||||
// Valid
|
||||
this._current = entry;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Partial state recovery
|
||||
state = state.state;
|
||||
} else {
|
||||
state = null;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
this._current.id = (typeof id === 'string' ? id : this._generateId());
|
||||
this._current.state = /** @type {import('display-history').EntryState} */ (state);
|
||||
this._current.content = null;
|
||||
this._clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @param {string} url
|
||||
* @param {?import('display-history').EntryState} state
|
||||
* @param {?import('display-history').EntryContent} content
|
||||
* @param {?import('display-history').Entry} previous
|
||||
* @returns {import('display-history').Entry}
|
||||
*/
|
||||
_createHistoryEntry(id, url, state, content, previous) {
|
||||
/** @type {import('display-history').Entry} */
|
||||
const entry = {
|
||||
id: typeof id === 'string' ? id : this._generateId(),
|
||||
url,
|
||||
next: null,
|
||||
previous,
|
||||
state,
|
||||
content,
|
||||
};
|
||||
this._historyMap.set(entry.id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
_generateId() {
|
||||
return generateId(16);
|
||||
}
|
||||
|
||||
/** */
|
||||
_clear() {
|
||||
this._historyMap.clear();
|
||||
this._historyMap.set(this._current.id, this._current);
|
||||
this._current.next = null;
|
||||
this._current.previous = null;
|
||||
}
|
||||
}
|
||||
135
vendor/yomitan/js/display/display-notification.js
vendored
Normal file
135
vendor/yomitan/js/display/display-notification.js
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 {EventListenerCollection} from '../core/event-listener-collection.js';
|
||||
import {querySelectorNotNull} from '../dom/query-selector.js';
|
||||
|
||||
export class DisplayNotification {
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {HTMLElement} node
|
||||
*/
|
||||
constructor(container, node) {
|
||||
/** @type {HTMLElement} */
|
||||
this._container = container;
|
||||
/** @type {HTMLElement} */
|
||||
this._node = node;
|
||||
/** @type {HTMLElement} */
|
||||
this._body = querySelectorNotNull(node, '.footer-notification-body');
|
||||
/** @type {HTMLElement} */
|
||||
this._closeButton = querySelectorNotNull(node, '.footer-notification-close-button');
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {?import('core').Timeout} */
|
||||
this._closeTimer = null;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get container() {
|
||||
return this._container;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get node() {
|
||||
return this._node;
|
||||
}
|
||||
|
||||
/** */
|
||||
open() {
|
||||
if (!this.isClosed()) { return; }
|
||||
|
||||
this._clearTimer();
|
||||
|
||||
const node = this._node;
|
||||
this._container.appendChild(node);
|
||||
const style = getComputedStyle(node);
|
||||
node.hidden = true;
|
||||
style.getPropertyValue('opacity'); // Force CSS update, allowing animation
|
||||
node.hidden = false;
|
||||
this._eventListeners.addEventListener(this._closeButton, 'click', this._onCloseButtonClick.bind(this), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} [animate]
|
||||
*/
|
||||
close(animate = false) {
|
||||
if (this.isClosed()) { return; }
|
||||
|
||||
if (animate) {
|
||||
if (this._closeTimer !== null) { return; }
|
||||
|
||||
this._node.hidden = true;
|
||||
this._closeTimer = setTimeout(this._onDelayClose.bind(this), 200);
|
||||
} else {
|
||||
this._clearTimer();
|
||||
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
const parent = this._node.parentNode;
|
||||
if (parent !== null) {
|
||||
parent.removeChild(this._node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|Node} value
|
||||
*/
|
||||
setContent(value) {
|
||||
if (typeof value === 'string') {
|
||||
this._body.textContent = value;
|
||||
} else {
|
||||
this._body.textContent = '';
|
||||
this._body.appendChild(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isClosing() {
|
||||
return this._closeTimer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isClosed() {
|
||||
return this._node.parentNode === null;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onCloseButtonClick() {
|
||||
this.close(true);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onDelayClose() {
|
||||
this._closeTimer = null;
|
||||
this.close(false);
|
||||
}
|
||||
|
||||
/** */
|
||||
_clearTimer() {
|
||||
if (this._closeTimer !== null) {
|
||||
clearTimeout(this._closeTimer);
|
||||
this._closeTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
156
vendor/yomitan/js/display/display-profile-selection.js
vendored
Normal file
156
vendor/yomitan/js/display/display-profile-selection.js
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 {generateId} from '../core/utilities.js';
|
||||
import {PanelElement} from '../dom/panel-element.js';
|
||||
import {querySelectorNotNull} from '../dom/query-selector.js';
|
||||
|
||||
export class DisplayProfileSelection {
|
||||
/**
|
||||
* @param {import('./display.js').Display} display
|
||||
*/
|
||||
constructor(display) {
|
||||
/** @type {import('./display.js').Display} */
|
||||
this._display = display;
|
||||
/** @type {HTMLElement} */
|
||||
this._profileList = querySelectorNotNull(document, '#profile-list');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._profileButton = querySelectorNotNull(document, '#profile-button');
|
||||
/** @type {HTMLElement} */
|
||||
const profilePanelElement = querySelectorNotNull(document, '#profile-panel');
|
||||
/** @type {PanelElement} */
|
||||
this._profilePanel = new PanelElement(profilePanelElement, 375); // Milliseconds; includes buffer
|
||||
/** @type {boolean} */
|
||||
this._profileListNeedsUpdate = false;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {string} */
|
||||
this._source = generateId(16);
|
||||
/** @type {HTMLElement} */
|
||||
this._profileName = querySelectorNotNull(document, '#profile-name');
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
this._display.application.on('optionsUpdated', this._onOptionsUpdated.bind(this));
|
||||
this._profileButton.addEventListener('click', this._onProfileButtonClick.bind(this), false);
|
||||
this._profileListNeedsUpdate = true;
|
||||
await this._updateCurrentProfileName();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {{source: string}} details
|
||||
*/
|
||||
async _onOptionsUpdated({source}) {
|
||||
if (source === this._source) { return; }
|
||||
this._profileListNeedsUpdate = true;
|
||||
if (this._profilePanel.isVisible()) {
|
||||
void this._updateProfileList();
|
||||
}
|
||||
await this._updateCurrentProfileName();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onProfileButtonClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._setProfilePanelVisible(!this._profilePanel.isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
_setProfilePanelVisible(visible) {
|
||||
this._profilePanel.setVisible(visible);
|
||||
this._profileButton.classList.toggle('sidebar-button-highlight', visible);
|
||||
document.documentElement.dataset.profilePanelVisible = `${visible}`;
|
||||
if (visible && this._profileListNeedsUpdate) {
|
||||
void this._updateProfileList();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateCurrentProfileName() {
|
||||
const {profileCurrent, profiles} = await this._display.application.api.optionsGetFull();
|
||||
if (profiles.length === 1) {
|
||||
this._profileButton.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const currentProfile = profiles[profileCurrent];
|
||||
this._profileName.textContent = currentProfile.name;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateProfileList() {
|
||||
this._profileListNeedsUpdate = false;
|
||||
const options = await this._display.application.api.optionsGetFull();
|
||||
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
const displayGenerator = this._display.displayGenerator;
|
||||
|
||||
const {profileCurrent, profiles} = options;
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0, ii = profiles.length; i < ii; ++i) {
|
||||
const {name} = profiles[i];
|
||||
const entry = displayGenerator.createProfileListItem();
|
||||
/** @type {HTMLInputElement} */
|
||||
const radio = querySelectorNotNull(entry, '.profile-entry-is-default-radio');
|
||||
radio.checked = (i === profileCurrent);
|
||||
/** @type {Element} */
|
||||
const nameNode = querySelectorNotNull(entry, '.profile-list-item-name');
|
||||
nameNode.textContent = name;
|
||||
fragment.appendChild(entry);
|
||||
this._eventListeners.addEventListener(radio, 'change', this._onProfileRadioChange.bind(this, i), false);
|
||||
}
|
||||
this._profileList.textContent = '';
|
||||
this._profileList.appendChild(fragment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onProfileRadioChange(index, e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
if (element.checked) {
|
||||
void this._setProfileCurrent(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
*/
|
||||
async _setProfileCurrent(index) {
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'profileCurrent',
|
||||
value: index,
|
||||
scope: 'global',
|
||||
optionsContext: null,
|
||||
};
|
||||
await this._display.application.api.modifySettings([modification], this._source);
|
||||
this._setProfilePanelVisible(false);
|
||||
await this._updateCurrentProfileName();
|
||||
}
|
||||
}
|
||||
229
vendor/yomitan/js/display/display-resizer.js
vendored
Normal file
229
vendor/yomitan/js/display/display-resizer.js
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
export class DisplayResizer {
|
||||
/**
|
||||
* @param {import('./display.js').Display} display
|
||||
*/
|
||||
constructor(display) {
|
||||
/** @type {import('./display.js').Display} */
|
||||
this._display = display;
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._token = null;
|
||||
/** @type {?HTMLElement} */
|
||||
this._handle = null;
|
||||
/** @type {?number} */
|
||||
this._touchIdentifier = null;
|
||||
/** @type {?{width: number, height: number}} */
|
||||
this._startSize = null;
|
||||
/** @type {?{x: number, y: number}} */
|
||||
this._startOffset = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._handle = document.querySelector('#frame-resizer-handle');
|
||||
if (this._handle === null) { return; }
|
||||
|
||||
this._handle.addEventListener('mousedown', this._onFrameResizerMouseDown.bind(this), false);
|
||||
this._handle.addEventListener('touchstart', this._onFrameResizerTouchStart.bind(this), {passive: false, capture: false});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onFrameResizerMouseDown(e) {
|
||||
if (e.button !== 0) { return; }
|
||||
// Don't do e.preventDefault() here; this allows mousemove events to be processed
|
||||
// if the pointer moves out of the frame.
|
||||
this._startFrameResize(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
_onFrameResizerTouchStart(e) {
|
||||
e.preventDefault();
|
||||
this._startFrameResizeTouch(e);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onFrameResizerMouseUp() {
|
||||
this._stopFrameResize();
|
||||
}
|
||||
|
||||
/** */
|
||||
_onFrameResizerWindowBlur() {
|
||||
this._stopFrameResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onFrameResizerMouseMove(e) {
|
||||
if ((e.buttons & 0x1) === 0x0) {
|
||||
this._stopFrameResize();
|
||||
} else {
|
||||
if (this._startSize === null) { return; }
|
||||
const {clientX: x, clientY: y} = e;
|
||||
void this._updateFrameSize(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
_onFrameResizerTouchEnd(e) {
|
||||
if (this._getTouch(e.changedTouches, this._touchIdentifier) === null) { return; }
|
||||
this._stopFrameResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
_onFrameResizerTouchCancel(e) {
|
||||
if (this._getTouch(e.changedTouches, this._touchIdentifier) === null) { return; }
|
||||
this._stopFrameResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
_onFrameResizerTouchMove(e) {
|
||||
if (this._startSize === null) { return; }
|
||||
const primaryTouch = this._getTouch(e.changedTouches, this._touchIdentifier);
|
||||
if (primaryTouch === null) { return; }
|
||||
const {clientX: x, clientY: y} = primaryTouch;
|
||||
void this._updateFrameSize(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_startFrameResize(e) {
|
||||
if (this._token !== null) { return; }
|
||||
|
||||
const {clientX: x, clientY: y} = e;
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._token = token;
|
||||
this._startOffset = {x, y};
|
||||
this._eventListeners.addEventListener(window, 'mouseup', this._onFrameResizerMouseUp.bind(this), false);
|
||||
this._eventListeners.addEventListener(window, 'blur', this._onFrameResizerWindowBlur.bind(this), false);
|
||||
this._eventListeners.addEventListener(window, 'mousemove', this._onFrameResizerMouseMove.bind(this), false);
|
||||
|
||||
const {documentElement} = document;
|
||||
if (documentElement !== null) {
|
||||
documentElement.dataset.isResizing = 'true';
|
||||
}
|
||||
|
||||
void this._initializeFrameResize(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
_startFrameResizeTouch(e) {
|
||||
if (this._token !== null) { return; }
|
||||
|
||||
const {clientX: x, clientY: y, identifier} = e.changedTouches[0];
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._token = token;
|
||||
this._startOffset = {x, y};
|
||||
this._touchIdentifier = identifier;
|
||||
this._eventListeners.addEventListener(window, 'touchend', this._onFrameResizerTouchEnd.bind(this), false);
|
||||
this._eventListeners.addEventListener(window, 'touchcancel', this._onFrameResizerTouchCancel.bind(this), false);
|
||||
this._eventListeners.addEventListener(window, 'blur', this._onFrameResizerWindowBlur.bind(this), false);
|
||||
this._eventListeners.addEventListener(window, 'touchmove', this._onFrameResizerTouchMove.bind(this), false);
|
||||
|
||||
const {documentElement} = document;
|
||||
if (documentElement !== null) {
|
||||
documentElement.dataset.isResizing = 'true';
|
||||
}
|
||||
|
||||
void this._initializeFrameResize(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('core').TokenObject} token
|
||||
*/
|
||||
async _initializeFrameResize(token) {
|
||||
const {parentPopupId} = this._display;
|
||||
if (parentPopupId === null) { return; }
|
||||
|
||||
/** @type {import('popup').ValidSize} */
|
||||
const size = await this._display.invokeParentFrame('popupFactoryGetFrameSize', {id: parentPopupId});
|
||||
if (this._token !== token) { return; }
|
||||
const {width, height} = size;
|
||||
this._startSize = {width, height};
|
||||
}
|
||||
|
||||
/** */
|
||||
_stopFrameResize() {
|
||||
if (this._token === null) { return; }
|
||||
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._startSize = null;
|
||||
this._startOffset = null;
|
||||
this._touchIdentifier = null;
|
||||
this._token = null;
|
||||
|
||||
const {documentElement} = document;
|
||||
if (documentElement !== null) {
|
||||
delete documentElement.dataset.isResizing;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
async _updateFrameSize(x, y) {
|
||||
const {parentPopupId} = this._display;
|
||||
if (parentPopupId === null || this._handle === null || this._startOffset === null || this._startSize === null) { return; }
|
||||
|
||||
const handleSize = this._handle.getBoundingClientRect();
|
||||
let {width, height} = this._startSize;
|
||||
width += x - this._startOffset.x;
|
||||
height += y - this._startOffset.y;
|
||||
width = Math.max(Math.max(0, handleSize.width), width);
|
||||
height = Math.max(Math.max(0, handleSize.height), height);
|
||||
await this._display.invokeParentFrame('popupFactorySetFrameSize', {id: parentPopupId, width, height});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchList} touchList
|
||||
* @param {?number} identifier
|
||||
* @returns {?Touch}
|
||||
*/
|
||||
_getTouch(touchList, identifier) {
|
||||
for (const touch of touchList) {
|
||||
if (touch.identifier === identifier) {
|
||||
return touch;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2362
vendor/yomitan/js/display/display.js
vendored
Normal file
2362
vendor/yomitan/js/display/display.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
197
vendor/yomitan/js/display/element-overflow-controller.js
vendored
Normal file
197
vendor/yomitan/js/display/element-overflow-controller.js
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
export class ElementOverflowController {
|
||||
/**
|
||||
* @param {import('./display.js').Display} display
|
||||
*/
|
||||
constructor(display) {
|
||||
/** @type {import('./display.js').Display} */
|
||||
this._display = display;
|
||||
/** @type {Element[]} */
|
||||
this._elements = [];
|
||||
/** @type {?(number|import('core').Timeout)} */
|
||||
this._checkTimer = null;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {EventListenerCollection} */
|
||||
this._windowEventListeners = new EventListenerCollection();
|
||||
/** @type {Map<string, {collapsed: boolean, force: boolean}>} */
|
||||
this._dictionaries = new Map();
|
||||
/** @type {() => void} */
|
||||
this._updateBind = this._update.bind(this);
|
||||
/** @type {() => void} */
|
||||
this._onWindowResizeBind = this._onWindowResize.bind(this);
|
||||
/** @type {(event: MouseEvent) => void} */
|
||||
this._onToggleButtonClickBind = this._onToggleButtonClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
*/
|
||||
setOptions(options) {
|
||||
this._dictionaries.clear();
|
||||
for (const {name, definitionsCollapsible} of options.dictionaries) {
|
||||
let collapsible = false;
|
||||
let collapsed = false;
|
||||
let force = false;
|
||||
switch (definitionsCollapsible) {
|
||||
case 'expanded':
|
||||
collapsible = true;
|
||||
break;
|
||||
case 'collapsed':
|
||||
collapsible = true;
|
||||
collapsed = true;
|
||||
break;
|
||||
case 'force-expanded':
|
||||
collapsible = true;
|
||||
force = true;
|
||||
break;
|
||||
case 'force-collapsed':
|
||||
collapsible = true;
|
||||
collapsed = true;
|
||||
force = true;
|
||||
break;
|
||||
}
|
||||
if (!collapsible) { continue; }
|
||||
this._dictionaries.set(name, {collapsed, force});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} entry
|
||||
*/
|
||||
addElements(entry) {
|
||||
if (this._dictionaries.size === 0) { return; }
|
||||
|
||||
|
||||
/** @type {Element[]} */
|
||||
const elements = [
|
||||
...entry.querySelectorAll('.definition-item-inner'),
|
||||
...entry.querySelectorAll('.kanji-glyph-data'),
|
||||
];
|
||||
for (const element of elements) {
|
||||
const {parentNode} = element;
|
||||
if (parentNode === null) { continue; }
|
||||
const {dictionary} = /** @type {HTMLElement} */ (parentNode).dataset;
|
||||
if (typeof dictionary === 'undefined') { continue; }
|
||||
const dictionaryInfo = this._dictionaries.get(dictionary);
|
||||
if (typeof dictionaryInfo === 'undefined') { continue; }
|
||||
|
||||
if (dictionaryInfo.force) {
|
||||
element.classList.add('collapsible', 'collapsible-forced');
|
||||
} else {
|
||||
this._updateElement(element);
|
||||
this._elements.push(element);
|
||||
}
|
||||
|
||||
if (dictionaryInfo.collapsed) {
|
||||
element.classList.add('collapsed');
|
||||
}
|
||||
|
||||
const button = element.querySelector('.expansion-button');
|
||||
if (button !== null) {
|
||||
this._eventListeners.addEventListener(button, 'click', this._onToggleButtonClickBind, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._elements.length > 0 && this._windowEventListeners.size === 0) {
|
||||
this._windowEventListeners.addEventListener(window, 'resize', this._onWindowResizeBind, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
clearElements() {
|
||||
this._elements.length = 0;
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._windowEventListeners.removeAllEventListeners();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onWindowResize() {
|
||||
if (this._checkTimer !== null) {
|
||||
this._cancelIdleCallback(this._checkTimer);
|
||||
}
|
||||
this._checkTimer = this._requestIdleCallback(this._updateBind, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onToggleButtonClick(e) {
|
||||
const element = /** @type {Element} */ (e.currentTarget);
|
||||
/** @type {(Element | null)[]} */
|
||||
const collapsedElements = [
|
||||
element.closest('.definition-item-inner'),
|
||||
element.closest('.kanji-glyph-data'),
|
||||
];
|
||||
for (const collapsedElement of collapsedElements) {
|
||||
if (collapsedElement === null) { continue; }
|
||||
const collapsed = collapsedElement.classList.toggle('collapsed');
|
||||
if (collapsed) {
|
||||
this._display.scrollUpToElementTop(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_update() {
|
||||
for (const element of this._elements) {
|
||||
this._updateElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
*/
|
||||
_updateElement(element) {
|
||||
const {classList} = element;
|
||||
classList.add('collapse-test');
|
||||
const collapsible = element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
||||
classList.toggle('collapsible', collapsible);
|
||||
classList.remove('collapse-test');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => void} callback
|
||||
* @param {number} timeout
|
||||
* @returns {number|import('core').Timeout}
|
||||
*/
|
||||
_requestIdleCallback(callback, timeout) {
|
||||
return (
|
||||
typeof requestIdleCallback === 'function' ?
|
||||
requestIdleCallback(callback, {timeout}) :
|
||||
setTimeout(callback, timeout)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|import('core').Timeout} handle
|
||||
*/
|
||||
_cancelIdleCallback(handle) {
|
||||
if (typeof cancelIdleCallback === 'function') {
|
||||
cancelIdleCallback(/** @type {number} */ (handle));
|
||||
} else {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
149
vendor/yomitan/js/display/media-drawing-worker.js
vendored
Normal file
149
vendor/yomitan/js/display/media-drawing-worker.js
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 {API} from '../comm/api.js';
|
||||
import {createApiMap, invokeApiMapHandler} from '../core/api-map.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
import {log} from '../core/log.js';
|
||||
import {WebExtension} from '../extension/web-extension.js';
|
||||
|
||||
export class MediaDrawingWorker {
|
||||
constructor() {
|
||||
/** @type {number} */
|
||||
this._generation = 0;
|
||||
|
||||
/** @type {MessagePort?} */
|
||||
this._dbPort = null;
|
||||
|
||||
/** @type {import('api').PmApiMap} */
|
||||
this._fromApplicationApiMap = createApiMap([
|
||||
['drawMedia', this._onDrawMedia.bind(this)],
|
||||
['connectToDatabaseWorker', this._onConnectToDatabaseWorker.bind(this)],
|
||||
]);
|
||||
|
||||
/** @type {import('api').PmApiMap} */
|
||||
this._fromDatabaseApiMap = createApiMap([
|
||||
['drawBufferToCanvases', this._onDrawBufferToCanvases.bind(this)],
|
||||
['drawDecodedImageToCanvases', this._onDrawDecodedImageToCanvases.bind(this)],
|
||||
]);
|
||||
|
||||
/** @type {Map<number, OffscreenCanvas[]>} */
|
||||
this._canvasesByGeneration = new Map();
|
||||
|
||||
/**
|
||||
* @type {API}
|
||||
*/
|
||||
this._api = new API(new WebExtension());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async prepare() {
|
||||
addEventListener('message', (event) => {
|
||||
/** @type {import('api').PmApiMessageAny} */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const message = event.data;
|
||||
return invokeApiMapHandler(this._fromApplicationApiMap, message.action, message.params, [event.ports], () => {});
|
||||
});
|
||||
addEventListener('messageerror', (event) => {
|
||||
const error = new ExtensionError('MediaDrawingWorker: Error receiving message from application');
|
||||
error.data = event;
|
||||
log.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {import('api').PmApiHandler<'drawMedia'>} */
|
||||
async _onDrawMedia({requests}) {
|
||||
this._generation++;
|
||||
this._canvasesByGeneration.set(this._generation, requests.map((request) => request.canvas));
|
||||
this._cleanOldGenerations();
|
||||
const newRequests = requests.map((request, index) => ({...request, canvas: null, generation: this._generation, canvasIndex: index, canvasWidth: request.canvas.width, canvasHeight: request.canvas.height}));
|
||||
if (this._dbPort !== null) {
|
||||
this._dbPort.postMessage({action: 'drawMedia', params: {requests: newRequests}});
|
||||
} else {
|
||||
log.error('no database port available');
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('api').PmApiHandler<'drawBufferToCanvases'>} */
|
||||
async _onDrawBufferToCanvases({buffer, width, height, canvasIndexes, generation}) {
|
||||
try {
|
||||
const canvases = this._canvasesByGeneration.get(generation);
|
||||
if (typeof canvases === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const imageData = new ImageData(new Uint8ClampedArray(buffer), width, height);
|
||||
for (const ci of canvasIndexes) {
|
||||
const c = canvases[ci];
|
||||
c.getContext('2d')?.putImageData(imageData, 0, 0);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('api').PmApiHandler<'drawDecodedImageToCanvases'>} */
|
||||
async _onDrawDecodedImageToCanvases({decodedImage, canvasIndexes, generation}) {
|
||||
try {
|
||||
const canvases = this._canvasesByGeneration.get(generation);
|
||||
if (typeof canvases === 'undefined') {
|
||||
return;
|
||||
}
|
||||
for (const ci of canvasIndexes) {
|
||||
const c = canvases[ci];
|
||||
c.getContext('2d')?.drawImage(decodedImage, 0, 0, c.width, c.height);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('api').PmApiHandler<'connectToDatabaseWorker'>} */
|
||||
async _onConnectToDatabaseWorker(_params, ports) {
|
||||
if (ports === null) {
|
||||
return;
|
||||
}
|
||||
const dbPort = ports[0];
|
||||
this._dbPort = dbPort;
|
||||
dbPort.addEventListener('message', (/** @type {MessageEvent<import('api').PmApiMessageAny>} */ event) => {
|
||||
const message = event.data;
|
||||
return invokeApiMapHandler(this._fromDatabaseApiMap, message.action, message.params, [event.ports], () => {});
|
||||
});
|
||||
dbPort.addEventListener('messageerror', (event) => {
|
||||
const error = new ExtensionError('MediaDrawingWorker: Error receiving message from database worker');
|
||||
error.data = event;
|
||||
log.error(error);
|
||||
});
|
||||
dbPort.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} keepNGenerations Number of generations to keep, defaults to 2 (the current generation and the one before it).
|
||||
*/
|
||||
_cleanOldGenerations(keepNGenerations = 2) {
|
||||
const generations = [...this._canvasesByGeneration.keys()];
|
||||
for (const g of generations) {
|
||||
if (g <= this._generation - keepNGenerations) {
|
||||
this._canvasesByGeneration.delete(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mediaDrawingWorker = new MediaDrawingWorker();
|
||||
await mediaDrawingWorker.prepare();
|
||||
195
vendor/yomitan/js/display/option-toggle-hotkey-handler.js
vendored
Normal file
195
vendor/yomitan/js/display/option-toggle-hotkey-handler.js
vendored
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 {ExtensionError} from '../core/extension-error.js';
|
||||
import {toError} from '../core/to-error.js';
|
||||
import {generateId} from '../core/utilities.js';
|
||||
|
||||
export class OptionToggleHotkeyHandler {
|
||||
/**
|
||||
* @param {import('./display.js').Display} display
|
||||
*/
|
||||
constructor(display) {
|
||||
/** @type {import('./display.js').Display} */
|
||||
this._display = display;
|
||||
/** @type {?import('./display-notification.js').DisplayNotification} */
|
||||
this._notification = null;
|
||||
/** @type {?import('core').Timeout} */
|
||||
this._notificationHideTimer = null;
|
||||
/** @type {number} */
|
||||
this._notificationHideTimeout = 5000;
|
||||
/** @type {string} */
|
||||
this._source = `option-toggle-hotkey-handler-${generateId(16)}`;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get notificationHideTimeout() {
|
||||
return this._notificationHideTimeout;
|
||||
}
|
||||
|
||||
set notificationHideTimeout(value) {
|
||||
this._notificationHideTimeout = value;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._display.hotkeyHandler.registerActions([
|
||||
['toggleOption', this._onHotkeyActionToggleOption.bind(this)],
|
||||
]);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {unknown} argument
|
||||
*/
|
||||
_onHotkeyActionToggleOption(argument) {
|
||||
if (typeof argument !== 'string') { return; }
|
||||
void this._toggleOption(argument);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
*/
|
||||
async _toggleOption(path) {
|
||||
let value;
|
||||
try {
|
||||
const optionsContext = this._display.getOptionsContext();
|
||||
|
||||
const getSettingsResponse = (await this._display.application.api.getSettings([{
|
||||
scope: 'profile',
|
||||
path,
|
||||
optionsContext,
|
||||
}]))[0];
|
||||
const {error: getSettingsError} = getSettingsResponse;
|
||||
if (typeof getSettingsError !== 'undefined') {
|
||||
throw ExtensionError.deserialize(getSettingsError);
|
||||
}
|
||||
|
||||
value = getSettingsResponse.result;
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`Option value of type ${typeof value} cannot be toggled`);
|
||||
}
|
||||
|
||||
value = !value;
|
||||
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
scope: 'profile',
|
||||
action: 'set',
|
||||
path,
|
||||
value,
|
||||
optionsContext,
|
||||
};
|
||||
const modifySettingsResponse = (await this._display.application.api.modifySettings([modification], this._source))[0];
|
||||
const {error: modifySettingsError} = modifySettingsResponse;
|
||||
if (typeof modifySettingsError !== 'undefined') {
|
||||
throw ExtensionError.deserialize(modifySettingsError);
|
||||
}
|
||||
|
||||
this._showNotification(this._createSuccessMessage(path, value), true);
|
||||
} catch (e) {
|
||||
this._showNotification(this._createErrorMessage(path, e), false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
_createSuccessMessage(path, value) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const n1 = document.createElement('em');
|
||||
n1.textContent = path;
|
||||
const n2 = document.createElement('strong');
|
||||
n2.textContent = `${value}`;
|
||||
fragment.appendChild(document.createTextNode('Option '));
|
||||
fragment.appendChild(n1);
|
||||
fragment.appendChild(document.createTextNode(' changed to '));
|
||||
fragment.appendChild(n2);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {unknown} error
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
_createErrorMessage(path, error) {
|
||||
const message = toError(error).message;
|
||||
const fragment = document.createDocumentFragment();
|
||||
const n1 = document.createElement('em');
|
||||
n1.textContent = path;
|
||||
const n2 = document.createElement('div');
|
||||
n2.textContent = message;
|
||||
n2.className = 'danger-text';
|
||||
fragment.appendChild(document.createTextNode('Failed to toggle option '));
|
||||
fragment.appendChild(n1);
|
||||
fragment.appendChild(document.createTextNode(': '));
|
||||
fragment.appendChild(n2);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DocumentFragment} message
|
||||
* @param {boolean} autoClose
|
||||
*/
|
||||
_showNotification(message, autoClose) {
|
||||
if (this._notification === null) {
|
||||
this._notification = this._display.createNotification(false);
|
||||
this._notification.node.addEventListener('click', this._onNotificationClick.bind(this), false);
|
||||
}
|
||||
|
||||
this._notification.setContent(message);
|
||||
this._notification.open();
|
||||
|
||||
this._stopHideNotificationTimer();
|
||||
if (autoClose) {
|
||||
this._notificationHideTimer = setTimeout(this._onNotificationHideTimeout.bind(this), this._notificationHideTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} animate
|
||||
*/
|
||||
_hideNotification(animate) {
|
||||
if (this._notification === null) { return; }
|
||||
this._notification.close(animate);
|
||||
this._stopHideNotificationTimer();
|
||||
}
|
||||
|
||||
/** */
|
||||
_stopHideNotificationTimer() {
|
||||
if (this._notificationHideTimer !== null) {
|
||||
clearTimeout(this._notificationHideTimer);
|
||||
this._notificationHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_onNotificationHideTimeout() {
|
||||
this._notificationHideTimer = null;
|
||||
this._hideNotification(true);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onNotificationClick() {
|
||||
this._stopHideNotificationTimer();
|
||||
}
|
||||
}
|
||||
53
vendor/yomitan/js/display/popup-main.js
vendored
Normal file
53
vendor/yomitan/js/display/popup-main.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 {HotkeyHandler} from '../input/hotkey-handler.js';
|
||||
import {DisplayAnki} from './display-anki.js';
|
||||
import {DisplayAudio} from './display-audio.js';
|
||||
import {DisplayProfileSelection} from './display-profile-selection.js';
|
||||
import {DisplayResizer} from './display-resizer.js';
|
||||
import {Display} from './display.js';
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const documentFocusController = new DocumentFocusController();
|
||||
documentFocusController.prepare();
|
||||
|
||||
const hotkeyHandler = new HotkeyHandler();
|
||||
hotkeyHandler.prepare(application.crossFrame);
|
||||
|
||||
const display = new Display(application, 'popup', documentFocusController, hotkeyHandler);
|
||||
await display.prepare();
|
||||
|
||||
const displayAudio = new DisplayAudio(display);
|
||||
displayAudio.prepare();
|
||||
|
||||
const displayAnki = new DisplayAnki(display, displayAudio);
|
||||
displayAnki.prepare();
|
||||
|
||||
const displayProfileSelection = new DisplayProfileSelection(display);
|
||||
void displayProfileSelection.prepare();
|
||||
|
||||
const displayResizer = new DisplayResizer(display);
|
||||
displayResizer.prepare();
|
||||
|
||||
display.initializeState();
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
441
vendor/yomitan/js/display/pronunciation-generator.js
vendored
Normal file
441
vendor/yomitan/js/display/pronunciation-generator.js
vendored
Normal file
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* 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 {getDownstepPositions, getKanaDiacriticInfo, isMoraPitchHigh} from '../language/ja/japanese.js';
|
||||
|
||||
export class PronunciationGenerator {
|
||||
/**
|
||||
* Creates a new instance of the class.
|
||||
* @param {Document} document
|
||||
*/
|
||||
constructor(document) {
|
||||
/** @type {Document} */
|
||||
this._document = document;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} morae
|
||||
* @param {number | string} pitchPositions
|
||||
* @param {number[]} nasalPositions
|
||||
* @param {number[]} devoicePositions
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
createPronunciationText(morae, pitchPositions, nasalPositions, devoicePositions) {
|
||||
const nasalPositionsSet = nasalPositions.length > 0 ? new Set(nasalPositions) : null;
|
||||
const devoicePositionsSet = devoicePositions.length > 0 ? new Set(devoicePositions) : null;
|
||||
const container = this._document.createElement('span');
|
||||
container.className = 'pronunciation-text';
|
||||
for (let i = 0, ii = morae.length; i < ii; ++i) {
|
||||
const i1 = i + 1;
|
||||
const mora = morae[i];
|
||||
const highPitch = isMoraPitchHigh(i, pitchPositions);
|
||||
const highPitchNext = isMoraPitchHigh(i1, pitchPositions);
|
||||
const nasal = nasalPositionsSet !== null && nasalPositionsSet.has(i1);
|
||||
const devoice = devoicePositionsSet !== null && devoicePositionsSet.has(i1);
|
||||
|
||||
const n1 = this._document.createElement('span');
|
||||
n1.className = 'pronunciation-mora';
|
||||
n1.dataset.position = `${i}`;
|
||||
n1.dataset.pitch = highPitch ? 'high' : 'low';
|
||||
n1.dataset.pitchNext = highPitchNext ? 'high' : 'low';
|
||||
|
||||
const characterNodes = [];
|
||||
for (const character of mora) {
|
||||
const n2 = this._document.createElement('span');
|
||||
n2.className = 'pronunciation-character';
|
||||
n2.textContent = character;
|
||||
n1.appendChild(n2);
|
||||
characterNodes.push(n2);
|
||||
}
|
||||
|
||||
if (devoice) {
|
||||
n1.dataset.devoice = 'true';
|
||||
const n3 = this._document.createElement('span');
|
||||
n3.className = 'pronunciation-devoice-indicator';
|
||||
n1.appendChild(n3);
|
||||
}
|
||||
if (nasal && characterNodes.length > 0) {
|
||||
n1.dataset.nasal = 'true';
|
||||
|
||||
const group = this._document.createElement('span');
|
||||
group.className = 'pronunciation-character-group';
|
||||
|
||||
const n2 = characterNodes[0];
|
||||
const character = /** @type {string} */ (n2.textContent);
|
||||
|
||||
const characterInfo = getKanaDiacriticInfo(character);
|
||||
if (characterInfo !== null) {
|
||||
n1.dataset.originalText = mora;
|
||||
n2.dataset.originalText = character;
|
||||
n2.textContent = characterInfo.character;
|
||||
}
|
||||
|
||||
let n3 = this._document.createElement('span');
|
||||
n3.className = 'pronunciation-nasal-diacritic';
|
||||
n3.textContent = '\u309a'; // Combining handakuten
|
||||
group.appendChild(n3);
|
||||
|
||||
n3 = this._document.createElement('span');
|
||||
n3.className = 'pronunciation-nasal-indicator';
|
||||
group.appendChild(n3);
|
||||
|
||||
/** @type {ParentNode} */ (n2.parentNode).replaceChild(group, n2);
|
||||
group.insertBefore(n2, group.firstChild);
|
||||
}
|
||||
|
||||
const line = this._document.createElement('span');
|
||||
line.className = 'pronunciation-mora-line';
|
||||
n1.appendChild(line);
|
||||
|
||||
container.appendChild(n1);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} morae
|
||||
* @param {number | string} pitchPositions
|
||||
* @returns {SVGSVGElement}
|
||||
*/
|
||||
createPronunciationGraph(morae, pitchPositions) {
|
||||
const ii = morae.length;
|
||||
|
||||
const svgns = 'http://www.w3.org/2000/svg';
|
||||
const svg = this._document.createElementNS(svgns, 'svg');
|
||||
svg.setAttribute('xmlns', svgns);
|
||||
svg.setAttribute('class', 'pronunciation-graph');
|
||||
svg.setAttribute('focusable', 'false');
|
||||
svg.setAttribute('viewBox', `0 0 ${50 * (ii + 1)} 100`);
|
||||
|
||||
if (ii <= 0) { return svg; }
|
||||
|
||||
const path1 = this._document.createElementNS(svgns, 'path');
|
||||
svg.appendChild(path1);
|
||||
|
||||
const path2 = this._document.createElementNS(svgns, 'path');
|
||||
svg.appendChild(path2);
|
||||
|
||||
const pathPoints = [];
|
||||
for (let i = 0; i < ii; ++i) {
|
||||
const highPitch = isMoraPitchHigh(i, pitchPositions);
|
||||
const highPitchNext = isMoraPitchHigh(i + 1, pitchPositions);
|
||||
const x = i * 50 + 25;
|
||||
const y = highPitch ? 25 : 75;
|
||||
if (highPitch && !highPitchNext) {
|
||||
this._addGraphDotDownstep(svg, svgns, x, y);
|
||||
} else {
|
||||
this._addGraphDot(svg, svgns, x, y);
|
||||
}
|
||||
pathPoints.push(`${x} ${y}`);
|
||||
}
|
||||
|
||||
path1.setAttribute('class', 'pronunciation-graph-line');
|
||||
path1.setAttribute('d', `M${pathPoints.join(' L')}`);
|
||||
|
||||
pathPoints.splice(0, ii - 1);
|
||||
{
|
||||
const highPitch = isMoraPitchHigh(ii, pitchPositions);
|
||||
const x = ii * 50 + 25;
|
||||
const y = highPitch ? 25 : 75;
|
||||
this._addGraphTriangle(svg, svgns, x, y);
|
||||
pathPoints.push(`${x} ${y}`);
|
||||
}
|
||||
|
||||
path2.setAttribute('class', 'pronunciation-graph-line-tail');
|
||||
path2.setAttribute('d', `M${pathPoints.join(' L')}`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number | string} downstepPositions
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
createPronunciationDownstepPosition(downstepPositions) {
|
||||
const downsteps = typeof downstepPositions === 'string' ? getDownstepPositions(downstepPositions) : downstepPositions;
|
||||
const downstepPositionString = `${downsteps}`;
|
||||
|
||||
const n1 = this._document.createElement('span');
|
||||
n1.className = 'pronunciation-downstep-notation';
|
||||
n1.dataset.downstepPosition = downstepPositionString;
|
||||
|
||||
let n2 = this._document.createElement('span');
|
||||
n2.className = 'pronunciation-downstep-notation-prefix';
|
||||
n2.textContent = '[';
|
||||
n1.appendChild(n2);
|
||||
|
||||
n2 = this._document.createElement('span');
|
||||
n2.className = 'pronunciation-downstep-notation-number';
|
||||
n2.textContent = downstepPositionString;
|
||||
n1.appendChild(n2);
|
||||
|
||||
n2 = this._document.createElement('span');
|
||||
n2.className = 'pronunciation-downstep-notation-suffix';
|
||||
n2.textContent = ']';
|
||||
n1.appendChild(n2);
|
||||
|
||||
return n1;
|
||||
}
|
||||
|
||||
// The following Jidoujisho pitch graph code is based on code from
|
||||
// https://github.com/lrorpilla/jidoujisho licensed under the
|
||||
// GNU General Public License v3.0
|
||||
|
||||
/**
|
||||
* Create a pronounciation graph in the style of Jidoujisho
|
||||
* @param {string[]} mora
|
||||
* @param {number | string} pitchPositions
|
||||
* @returns {SVGSVGElement}
|
||||
*/
|
||||
createPronunciationGraphJJ(mora, pitchPositions) {
|
||||
const patt = this._pitchValueToPattJJ(mora.length, pitchPositions);
|
||||
|
||||
const positions = Math.max(mora.length, patt.length);
|
||||
const stepWidth = 35;
|
||||
const marginLr = 16;
|
||||
const svgWidth = Math.max(0, ((positions - 1) * stepWidth) + (marginLr * 2));
|
||||
|
||||
const svgns = 'http://www.w3.org/2000/svg';
|
||||
const svg = this._document.createElementNS(svgns, 'svg');
|
||||
svg.setAttribute('xmlns', svgns);
|
||||
svg.setAttribute('width', `${(svgWidth * (3 / 5))}px`);
|
||||
svg.setAttribute('height', '45px');
|
||||
svg.setAttribute('viewBox', `0 0 ${svgWidth} 75`);
|
||||
|
||||
|
||||
if (mora.length <= 0) { return svg; }
|
||||
|
||||
for (let i = 0; i < mora.length; i++) {
|
||||
const xCenter = marginLr + (i * stepWidth);
|
||||
this._textJJ(xCenter - 11, mora[i], svgns, svg);
|
||||
}
|
||||
|
||||
|
||||
let pathType = '';
|
||||
|
||||
const circles = [];
|
||||
const paths = [];
|
||||
|
||||
let prevCenter = [-1, -1];
|
||||
for (let i = 0; i < patt.length; i++) {
|
||||
const xCenter = marginLr + (i * stepWidth);
|
||||
const accent = patt[i];
|
||||
let yCenter = 0;
|
||||
if (accent === 'H') {
|
||||
yCenter = 5;
|
||||
} else if (accent === 'L') {
|
||||
yCenter = 30;
|
||||
}
|
||||
circles.push(this._circleJJ(xCenter, yCenter, i >= mora.length, svgns));
|
||||
|
||||
|
||||
if (i > 0) {
|
||||
if (prevCenter[1] === yCenter) {
|
||||
pathType = 's';
|
||||
} else if (prevCenter[1] < yCenter) {
|
||||
pathType = 'd';
|
||||
} else if (prevCenter[1] > yCenter) {
|
||||
pathType = 'u';
|
||||
}
|
||||
paths.push(this._pathJJ(prevCenter[0], prevCenter[1], pathType, stepWidth, svgns));
|
||||
}
|
||||
prevCenter = [xCenter, yCenter];
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
svg.appendChild(path);
|
||||
}
|
||||
|
||||
for (const circle of circles) {
|
||||
svg.appendChild(circle);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {Element} container
|
||||
* @param {string} svgns
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
_addGraphDot(container, svgns, x, y) {
|
||||
container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot', x, y, '15'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} container
|
||||
* @param {string} svgns
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
_addGraphDotDownstep(container, svgns, x, y) {
|
||||
container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot-downstep1', x, y, '15'));
|
||||
container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot-downstep2', x, y, '5'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} container
|
||||
* @param {string} svgns
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
_addGraphTriangle(container, svgns, x, y) {
|
||||
const node = this._document.createElementNS(svgns, 'path');
|
||||
node.setAttribute('class', 'pronunciation-graph-triangle');
|
||||
node.setAttribute('d', 'M0 13 L15 -13 L-15 -13 Z');
|
||||
node.setAttribute('transform', `translate(${x},${y})`);
|
||||
container.appendChild(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} svgns
|
||||
* @param {string} className
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {string} radius
|
||||
* @returns {Element}
|
||||
*/
|
||||
_createGraphCircle(svgns, className, x, y, radius) {
|
||||
const node = this._document.createElementNS(svgns, 'circle');
|
||||
node.setAttribute('class', className);
|
||||
node.setAttribute('cx', `${x}`);
|
||||
node.setAttribute('cy', `${y}`);
|
||||
node.setAttribute('r', radius);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get H&L pattern
|
||||
* @param {number} numberOfMora
|
||||
* @param {number | string} pitchValue
|
||||
* @returns {string}
|
||||
*/
|
||||
_pitchValueToPattJJ(numberOfMora, pitchValue) {
|
||||
if (typeof pitchValue === 'string') { return pitchValue + pitchValue[pitchValue.length - 1]; }
|
||||
if (numberOfMora >= 1) {
|
||||
if (pitchValue === 0) {
|
||||
// Heiban
|
||||
return `L${'H'.repeat(numberOfMora)}`;
|
||||
} else if (pitchValue === 1) {
|
||||
// Atamadaka
|
||||
return `H${'L'.repeat(numberOfMora)}`;
|
||||
} else if (pitchValue >= 2) {
|
||||
const stepdown = pitchValue - 2;
|
||||
return `LH${'H'.repeat(stepdown)}${'L'.repeat(numberOfMora - pitchValue + 1)}`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {boolean} o
|
||||
* @param {string} svgns
|
||||
* @returns {Element}
|
||||
*/
|
||||
_circleJJ(x, y, o, svgns) {
|
||||
if (o) {
|
||||
const node = this._document.createElementNS(svgns, 'circle');
|
||||
|
||||
node.setAttribute('r', '4');
|
||||
node.setAttribute('cx', `${(x + 4)}`);
|
||||
node.setAttribute('cy', `${y}`);
|
||||
node.setAttribute('stroke', 'currentColor');
|
||||
node.setAttribute('stroke-width', '2');
|
||||
node.setAttribute('fill', 'none');
|
||||
|
||||
return node;
|
||||
} else {
|
||||
const node = this._document.createElementNS(svgns, 'circle');
|
||||
|
||||
node.setAttribute('r', '5');
|
||||
node.setAttribute('cx', `${x}`);
|
||||
node.setAttribute('cy', `${y}`);
|
||||
node.setAttribute('style', 'opacity:1;fill:currentColor;');
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {string} mora
|
||||
* @param {string} svgns
|
||||
* @param {SVGSVGElement} svg
|
||||
* @returns {void}
|
||||
*/
|
||||
_textJJ(x, mora, svgns, svg) {
|
||||
if (mora.length === 1) {
|
||||
const path = this._document.createElementNS(svgns, 'text');
|
||||
path.setAttribute('x', `${x}`);
|
||||
path.setAttribute('y', '67.5');
|
||||
path.setAttribute('style', 'font-size:20px;font-family:sans-serif;fill:currentColor;');
|
||||
path.textContent = mora;
|
||||
svg.appendChild(path);
|
||||
} else {
|
||||
const path1 = this._document.createElementNS(svgns, 'text');
|
||||
path1.setAttribute('x', `${x - 5}`);
|
||||
path1.setAttribute('y', '67.5');
|
||||
path1.setAttribute('style', 'font-size:20px;font-family:sans-serif;fill:currentColor;');
|
||||
path1.textContent = mora[0];
|
||||
svg.appendChild(path1);
|
||||
|
||||
|
||||
const path2 = this._document.createElementNS(svgns, 'text');
|
||||
path2.setAttribute('x', `${x + 12}`);
|
||||
path2.setAttribute('y', '67.5');
|
||||
path2.setAttribute('style', 'font-size:14px;font-family:sans-serif;fill:currentColor;');
|
||||
path2.textContent = mora[1];
|
||||
svg.appendChild(path2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {string} type
|
||||
* @param {number} stepWidth
|
||||
* @param {string} svgns
|
||||
* @returns {Element}
|
||||
*/
|
||||
_pathJJ(x, y, type, stepWidth, svgns) {
|
||||
let delta = '';
|
||||
switch (type) {
|
||||
case 's':
|
||||
delta = stepWidth + ',0';
|
||||
break;
|
||||
case 'u':
|
||||
delta = stepWidth + ',-25';
|
||||
break;
|
||||
case 'd':
|
||||
delta = stepWidth + ',25';
|
||||
break;
|
||||
}
|
||||
|
||||
const path = this._document.createElementNS(svgns, 'path');
|
||||
path.setAttribute('d', `m ${x},${y} ${delta}`);
|
||||
path.setAttribute('style', 'fill:none;stroke:currentColor;stroke-width:1.5;');
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
429
vendor/yomitan/js/display/query-parser.js
vendored
Normal file
429
vendor/yomitan/js/display/query-parser.js
vendored
Normal file
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* 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 {log} from '../core/log.js';
|
||||
import {trimTrailingWhitespacePlusSpace} from '../data/string-util.js';
|
||||
import {querySelectorNotNull} from '../dom/query-selector.js';
|
||||
import {convertHiraganaToKatakana, convertKatakanaToHiragana, isStringEntirelyKana} from '../language/ja/japanese.js';
|
||||
import {TextScanner} from '../language/text-scanner.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('query-parser').Events>
|
||||
*/
|
||||
export class QueryParser extends EventDispatcher {
|
||||
/**
|
||||
* @param {import('../comm/api.js').API} api
|
||||
* @param {import('../dom/text-source-generator').TextSourceGenerator} textSourceGenerator
|
||||
* @param {import('display').GetSearchContextCallback} getSearchContext
|
||||
*/
|
||||
constructor(api, textSourceGenerator, getSearchContext) {
|
||||
super();
|
||||
/** @type {import('../comm/api.js').API} */
|
||||
this._api = api;
|
||||
/** @type {import('display').GetSearchContextCallback} */
|
||||
this._getSearchContext = getSearchContext;
|
||||
/** @type {string} */
|
||||
this._text = '';
|
||||
/** @type {?import('core').TokenObject} */
|
||||
this._setTextToken = null;
|
||||
/** @type {?string} */
|
||||
this._selectedParser = null;
|
||||
/** @type {import('settings').ParsingReadingMode} */
|
||||
this._readingMode = 'none';
|
||||
/** @type {number} */
|
||||
this._scanLength = 1;
|
||||
/** @type {boolean} */
|
||||
this._useInternalParser = true;
|
||||
/** @type {boolean} */
|
||||
this._useMecabParser = false;
|
||||
/** @type {import('api').ParseTextResultItem[]} */
|
||||
this._parseResults = [];
|
||||
/** @type {HTMLElement} */
|
||||
this._queryParser = querySelectorNotNull(document, '#query-parser-content');
|
||||
/** @type {HTMLElement} */
|
||||
this._queryParserModeContainer = querySelectorNotNull(document, '#query-parser-mode-container');
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._queryParserModeSelect = querySelectorNotNull(document, '#query-parser-mode-select');
|
||||
/** @type {TextScanner} */
|
||||
this._textScanner = new TextScanner({
|
||||
api,
|
||||
node: this._queryParser,
|
||||
getSearchContext,
|
||||
searchTerms: true,
|
||||
searchKanji: false,
|
||||
searchOnClick: true,
|
||||
textSourceGenerator,
|
||||
});
|
||||
/** @type {?(import('../language/ja/japanese-wanakana.js'))} */
|
||||
this._japaneseWanakanaModule = null;
|
||||
/** @type {?Promise<import('../language/ja/japanese-wanakana.js')>} */
|
||||
this._japaneseWanakanaModuleImport = null;
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
get text() {
|
||||
return this._text;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._textScanner.prepare();
|
||||
this._textScanner.on('clear', this._onTextScannerClear.bind(this));
|
||||
this._textScanner.on('searchSuccess', this._onSearchSuccess.bind(this));
|
||||
this._textScanner.on('searchError', this._onSearchError.bind(this));
|
||||
this._queryParserModeSelect.addEventListener('change', this._onParserChange.bind(this), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('display').QueryParserOptions} display
|
||||
*/
|
||||
setOptions({selectedParser, termSpacing, readingMode, useInternalParser, useMecabParser, language, scanning}) {
|
||||
let selectedParserChanged = false;
|
||||
if (selectedParser === null || typeof selectedParser === 'string') {
|
||||
selectedParserChanged = (this._selectedParser !== selectedParser);
|
||||
this._selectedParser = selectedParser;
|
||||
}
|
||||
if (typeof termSpacing === 'boolean') {
|
||||
this._queryParser.dataset.termSpacing = `${termSpacing}`;
|
||||
}
|
||||
if (typeof readingMode === 'string') {
|
||||
this._setReadingMode(readingMode);
|
||||
}
|
||||
if (typeof useInternalParser === 'boolean') {
|
||||
this._useInternalParser = useInternalParser;
|
||||
}
|
||||
if (typeof useMecabParser === 'boolean') {
|
||||
this._useMecabParser = useMecabParser;
|
||||
}
|
||||
if (scanning !== null && typeof scanning === 'object') {
|
||||
const {scanLength} = scanning;
|
||||
if (typeof scanLength === 'number') {
|
||||
this._scanLength = scanLength;
|
||||
}
|
||||
this._textScanner.language = language;
|
||||
this._textScanner.setOptions(scanning);
|
||||
this._textScanner.setEnabled(true);
|
||||
}
|
||||
|
||||
if (selectedParserChanged && this._parseResults.length > 0) {
|
||||
this._renderParseResult();
|
||||
}
|
||||
|
||||
this._queryParser.lang = language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
async setText(text) {
|
||||
this._text = text;
|
||||
this._setPreview(text);
|
||||
|
||||
if (this._useInternalParser === false && this._useMecabParser === false) {
|
||||
return;
|
||||
}
|
||||
/** @type {?import('core').TokenObject} */
|
||||
const token = {};
|
||||
this._setTextToken = token;
|
||||
this._parseResults = await this._api.parseText(text, this._getOptionsContext(), this._scanLength, this._useInternalParser, this._useMecabParser);
|
||||
if (this._setTextToken !== token) { return; }
|
||||
|
||||
this._refreshSelectedParser();
|
||||
|
||||
this._renderParserSelect();
|
||||
this._renderParseResult();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onTextScannerClear() {
|
||||
this._textScanner.clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('text-scanner').EventArgument<'searchSuccess'>} details
|
||||
*/
|
||||
_onSearchSuccess({type, dictionaryEntries, sentence, inputInfo, textSource, optionsContext, pageTheme}) {
|
||||
this.trigger('searched', {
|
||||
textScanner: this._textScanner,
|
||||
type,
|
||||
dictionaryEntries,
|
||||
sentence,
|
||||
inputInfo,
|
||||
textSource,
|
||||
optionsContext,
|
||||
sentenceOffset: this._getSentenceOffset(textSource),
|
||||
pageTheme: pageTheme,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('text-scanner').EventArgument<'searchError'>} details
|
||||
*/
|
||||
_onSearchError({error}) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onParserChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
|
||||
const value = element.value;
|
||||
this._setSelectedParser(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('settings').OptionsContext}
|
||||
*/
|
||||
_getOptionsContext() {
|
||||
return this._getSearchContext().optionsContext;
|
||||
}
|
||||
|
||||
/** */
|
||||
_refreshSelectedParser() {
|
||||
if (this._parseResults.length > 0 && !this._getParseResult()) {
|
||||
const value = this._parseResults[0].id;
|
||||
this._setSelectedParser(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
_setSelectedParser(value) {
|
||||
const optionsContext = this._getOptionsContext();
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'parsing.selectedParser',
|
||||
value,
|
||||
scope: 'profile',
|
||||
optionsContext,
|
||||
};
|
||||
void this._api.modifySettings([modification], 'search');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('api').ParseTextResultItem|undefined}
|
||||
*/
|
||||
_getParseResult() {
|
||||
const selectedParser = this._selectedParser;
|
||||
return this._parseResults.find((r) => r.id === selectedParser);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
_setPreview(text) {
|
||||
const terms = [[{text, reading: ''}]];
|
||||
this._queryParser.textContent = '';
|
||||
this._queryParser.dataset.parsed = 'false';
|
||||
this._queryParser.appendChild(this._createParseResult(terms));
|
||||
}
|
||||
|
||||
/** */
|
||||
_renderParserSelect() {
|
||||
const visible = (this._parseResults.length > 1);
|
||||
if (visible) {
|
||||
this._updateParserModeSelect(this._queryParserModeSelect, this._parseResults, this._selectedParser);
|
||||
}
|
||||
this._queryParserModeContainer.hidden = !visible;
|
||||
}
|
||||
|
||||
/** */
|
||||
_renderParseResult() {
|
||||
const parseResult = this._getParseResult();
|
||||
this._queryParser.textContent = '';
|
||||
this._queryParser.dataset.parsed = 'true';
|
||||
if (!parseResult) { return; }
|
||||
this._queryParser.appendChild(this._createParseResult(parseResult.content));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLSelectElement} select
|
||||
* @param {import('api').ParseTextResultItem[]} parseResults
|
||||
* @param {?string} selectedParser
|
||||
*/
|
||||
_updateParserModeSelect(select, parseResults, selectedParser) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
let index = 0;
|
||||
let selectedIndex = -1;
|
||||
for (const parseResult of parseResults) {
|
||||
const option = document.createElement('option');
|
||||
option.value = parseResult.id;
|
||||
switch (parseResult.source) {
|
||||
case 'scanning-parser':
|
||||
option.textContent = 'Scanning parser';
|
||||
break;
|
||||
case 'mecab':
|
||||
option.textContent = `MeCab: ${parseResult.dictionary}`;
|
||||
break;
|
||||
default:
|
||||
option.textContent = `Unknown source: ${parseResult.source}`;
|
||||
break;
|
||||
}
|
||||
fragment.appendChild(option);
|
||||
|
||||
if (selectedParser === parseResult.id) {
|
||||
selectedIndex = index;
|
||||
}
|
||||
++index;
|
||||
}
|
||||
|
||||
select.textContent = '';
|
||||
select.appendChild(fragment);
|
||||
select.selectedIndex = selectedIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('api').ParseTextLine[]} data
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
_createParseResult(data) {
|
||||
let offset = 0;
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const term = data[i];
|
||||
const termNode = document.createElement('span');
|
||||
termNode.className = 'query-parser-term';
|
||||
termNode.dataset.offset = `${offset}`;
|
||||
for (const {text, reading} of term) {
|
||||
// trimEnd only for final text
|
||||
const trimmedText = i === data.length - 1 ? text.trimEnd() : trimTrailingWhitespacePlusSpace(text);
|
||||
if (reading.length === 0) {
|
||||
termNode.appendChild(document.createTextNode(trimmedText));
|
||||
} else {
|
||||
const reading2 = this._convertReading(trimmedText, reading);
|
||||
termNode.appendChild(this._createSegment(trimmedText, reading2, offset));
|
||||
}
|
||||
offset += trimmedText.length;
|
||||
}
|
||||
fragment.appendChild(termNode);
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string} reading
|
||||
* @param {number} offset
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createSegment(text, reading, offset) {
|
||||
const segmentNode = document.createElement('ruby');
|
||||
segmentNode.className = 'query-parser-segment';
|
||||
|
||||
const textNode = document.createElement('span');
|
||||
textNode.className = 'query-parser-segment-text';
|
||||
textNode.dataset.offset = `${offset}`;
|
||||
|
||||
const readingNode = document.createElement('rt');
|
||||
readingNode.className = 'query-parser-segment-reading';
|
||||
|
||||
segmentNode.appendChild(textNode);
|
||||
segmentNode.appendChild(readingNode);
|
||||
|
||||
textNode.textContent = text;
|
||||
readingNode.textContent = reading;
|
||||
|
||||
return segmentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert _reading_ to hiragana, katakana, or romaji, or _term_ if it is entirely kana and _reading_ is an empty string, based on _readingMode.
|
||||
* @param {string} term
|
||||
* @param {string} reading
|
||||
* @returns {string}
|
||||
*/
|
||||
_convertReading(term, reading) {
|
||||
switch (this._readingMode) {
|
||||
case 'hiragana':
|
||||
return convertKatakanaToHiragana(reading);
|
||||
case 'katakana':
|
||||
return convertHiraganaToKatakana(reading);
|
||||
case 'romaji':
|
||||
if (this._japaneseWanakanaModule !== null) {
|
||||
if (reading.length > 0) {
|
||||
return this._japaneseWanakanaModule.convertToRomaji(reading);
|
||||
} else if (isStringEntirelyKana(term)) {
|
||||
return this._japaneseWanakanaModule.convertToRomaji(term);
|
||||
}
|
||||
}
|
||||
return reading;
|
||||
case 'none':
|
||||
return '';
|
||||
default:
|
||||
return reading;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('text-source').TextSource} textSource
|
||||
* @returns {?number}
|
||||
*/
|
||||
_getSentenceOffset(textSource) {
|
||||
if (textSource.type === 'range') {
|
||||
const {range} = textSource;
|
||||
const node = this._getParentElement(range.startContainer);
|
||||
if (node !== null && node instanceof HTMLElement) {
|
||||
const {offset} = node.dataset;
|
||||
if (typeof offset === 'string') {
|
||||
const value = Number.parseInt(offset, 10);
|
||||
if (Number.isFinite(value)) {
|
||||
return Math.max(0, value) + range.startOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?Node} node
|
||||
* @returns {?Element}
|
||||
*/
|
||||
_getParentElement(node) {
|
||||
const {ELEMENT_NODE} = Node;
|
||||
while (true) {
|
||||
if (node === null) { return null; }
|
||||
if (node.nodeType === ELEMENT_NODE) { return /** @type {Element} */ (node); }
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ParsingReadingMode} value
|
||||
*/
|
||||
_setReadingMode(value) {
|
||||
this._readingMode = value;
|
||||
if (value === 'romaji') {
|
||||
this._loadJapaneseWanakanaModule();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_loadJapaneseWanakanaModule() {
|
||||
if (this._japaneseWanakanaModuleImport !== null) { return; }
|
||||
this._japaneseWanakanaModuleImport = import('../language/ja/japanese-wanakana.js');
|
||||
void this._japaneseWanakanaModuleImport.then((value) => { this._japaneseWanakanaModule = value; });
|
||||
}
|
||||
}
|
||||
41
vendor/yomitan/js/display/search-action-popup-controller.js
vendored
Normal file
41
vendor/yomitan/js/display/search-action-popup-controller.js
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export class SearchActionPopupController {
|
||||
/**
|
||||
* @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController
|
||||
*/
|
||||
constructor(searchPersistentStateController) {
|
||||
/** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */
|
||||
this._searchPersistentStateController = searchPersistentStateController;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (searchParams.get('action-popup') !== 'true') { return; }
|
||||
|
||||
searchParams.delete('action-popup');
|
||||
let search = searchParams.toString();
|
||||
if (search.length > 0) { search = `?${search}`; }
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}${search}${location.hash}`;
|
||||
history.replaceState(history.state, '', url);
|
||||
|
||||
this._searchPersistentStateController.mode = 'action-popup';
|
||||
}
|
||||
}
|
||||
719
vendor/yomitan/js/display/search-display-controller.js
vendored
Normal file
719
vendor/yomitan/js/display/search-display-controller.js
vendored
Normal file
@@ -0,0 +1,719 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2016-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 {ClipboardMonitor} from '../comm/clipboard-monitor.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 {isComposing} from '../language/ime-utilities.js';
|
||||
import {convertToKana, convertToKanaIME} from '../language/ja/japanese-wanakana.js';
|
||||
|
||||
export class SearchDisplayController {
|
||||
/**
|
||||
* @param {import('./display.js').Display} display
|
||||
* @param {import('./display-audio.js').DisplayAudio} displayAudio
|
||||
* @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController
|
||||
*/
|
||||
constructor(display, displayAudio, searchPersistentStateController) {
|
||||
/** @type {import('./display.js').Display} */
|
||||
this._display = display;
|
||||
/** @type {import('./display-audio.js').DisplayAudio} */
|
||||
this._displayAudio = displayAudio;
|
||||
/** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */
|
||||
this._searchPersistentStateController = searchPersistentStateController;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._searchButton = querySelectorNotNull(document, '#search-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._clearButton = querySelectorNotNull(document, '#clear-button');
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._searchBackButton = querySelectorNotNull(document, '#search-back-button');
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
this._queryInput = querySelectorNotNull(document, '#search-textbox');
|
||||
/** @type {HTMLElement} */
|
||||
this._introElement = querySelectorNotNull(document, '#intro');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._clipboardMonitorEnableCheckbox = querySelectorNotNull(document, '#clipboard-monitor-enable');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._wanakanaEnableCheckbox = querySelectorNotNull(document, '#wanakana-enable');
|
||||
/** @type {HTMLInputElement} */
|
||||
this._stickyHeaderEnableCheckbox = querySelectorNotNull(document, '#sticky-header-enable');
|
||||
/** @type {HTMLElement} */
|
||||
this._profileSelectContainer = querySelectorNotNull(document, '#search-option-profile-select');
|
||||
/** @type {HTMLSelectElement} */
|
||||
this._profileSelect = querySelectorNotNull(document, '#profile-select');
|
||||
/** @type {HTMLElement} */
|
||||
this._wanakanaSearchOption = querySelectorNotNull(document, '#search-option-wanakana');
|
||||
/** @type {EventListenerCollection} */
|
||||
this._queryInputEvents = new EventListenerCollection();
|
||||
/** @type {boolean} */
|
||||
this._queryInputEventsSetup = false;
|
||||
/** @type {boolean} */
|
||||
this._wanakanaEnabled = false;
|
||||
/** @type {boolean} */
|
||||
this._introVisible = true;
|
||||
/** @type {?import('core').Timeout} */
|
||||
this._introAnimationTimer = null;
|
||||
/** @type {boolean} */
|
||||
this._clipboardMonitorEnabled = false;
|
||||
/** @type {import('clipboard-monitor').ClipboardReaderLike} */
|
||||
this._clipboardReaderLike = {
|
||||
getText: this._display.application.api.clipboardGet.bind(this._display.application.api),
|
||||
};
|
||||
/** @type {ClipboardMonitor} */
|
||||
this._clipboardMonitor = new ClipboardMonitor(this._clipboardReaderLike);
|
||||
/** @type {import('application').ApiMap} */
|
||||
this._apiMap = createApiMap([
|
||||
['searchDisplayControllerGetMode', this._onMessageGetMode.bind(this)],
|
||||
['searchDisplayControllerSetMode', this._onMessageSetMode.bind(this)],
|
||||
['searchDisplayControllerUpdateSearchQuery', this._onExternalSearchUpdate.bind(this)],
|
||||
]);
|
||||
}
|
||||
|
||||
/** */
|
||||
async prepare() {
|
||||
await this._display.updateOptions();
|
||||
|
||||
this._searchPersistentStateController.on('modeChange', this._onModeChange.bind(this));
|
||||
|
||||
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
||||
this._display.application.on('optionsUpdated', this._onOptionsUpdated.bind(this));
|
||||
|
||||
this._display.on('optionsUpdated', this._onDisplayOptionsUpdated.bind(this));
|
||||
this._display.on('contentUpdateStart', this._onContentUpdateStart.bind(this));
|
||||
|
||||
this._display.hotkeyHandler.registerActions([
|
||||
['focusSearchBox', this._onActionFocusSearchBox.bind(this)],
|
||||
]);
|
||||
|
||||
this._updateClipboardMonitorEnabled();
|
||||
|
||||
this._displayAudio.autoPlayAudioDelay = 0;
|
||||
this._display.queryParserVisible = true;
|
||||
this._display.setHistorySettings({useBrowserHistory: true});
|
||||
|
||||
this._searchButton.addEventListener('click', this._onSearch.bind(this), false);
|
||||
this._clearButton.addEventListener('click', this._onClear.bind(this), false);
|
||||
|
||||
this._searchBackButton.addEventListener('click', this._onSearchBackButtonClick.bind(this), false);
|
||||
this._wanakanaEnableCheckbox.addEventListener('change', this._onWanakanaEnableChange.bind(this));
|
||||
window.addEventListener('copy', this._onCopy.bind(this));
|
||||
window.addEventListener('paste', this._onPaste.bind(this));
|
||||
this._clipboardMonitor.on('change', this._onClipboardMonitorChange.bind(this));
|
||||
this._clipboardMonitorEnableCheckbox.addEventListener('change', this._onClipboardMonitorEnableChange.bind(this));
|
||||
this._stickyHeaderEnableCheckbox.addEventListener('change', this._onStickyHeaderEnableChange.bind(this));
|
||||
this._display.hotkeyHandler.on('keydownNonHotkey', this._onKeyDown.bind(this));
|
||||
|
||||
this._profileSelect.addEventListener('change', this._onProfileSelectChange.bind(this), false);
|
||||
|
||||
const displayOptions = this._display.getOptions();
|
||||
if (displayOptions !== null) {
|
||||
await this._onDisplayOptionsUpdated({options: displayOptions});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('display').SearchMode} mode
|
||||
*/
|
||||
setMode(mode) {
|
||||
this._searchPersistentStateController.mode = mode;
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
/** */
|
||||
_onActionFocusSearchBox() {
|
||||
if (this._queryInput === null) { return; }
|
||||
this._queryInput.focus();
|
||||
this._queryInput.select();
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
/** @type {import('application').ApiHandler<'searchDisplayControllerSetMode'>} */
|
||||
_onMessageSetMode({mode}) {
|
||||
this.setMode(mode);
|
||||
}
|
||||
|
||||
/** @type {import('application').ApiHandler<'searchDisplayControllerGetMode'>} */
|
||||
_onMessageGetMode() {
|
||||
return this._searchPersistentStateController.mode;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */
|
||||
_onMessage({action, params}, _sender, callback) {
|
||||
return invokeApiMapHandler(this._apiMap, action, params, [], callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_onKeyDown(e) {
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
const isInputField = this._isElementInput(activeElement);
|
||||
const isAllowedKey = e.key.length === 1 || e.key === 'Backspace';
|
||||
const isModifierKey = e.ctrlKey || e.metaKey || e.altKey;
|
||||
const isSpaceKey = e.key === ' ';
|
||||
const isCtrlBackspace = e.ctrlKey && e.key === 'Backspace';
|
||||
|
||||
if (!isInputField && (!isModifierKey || isCtrlBackspace) && isAllowedKey && !isSpaceKey) {
|
||||
this._queryInput.focus({preventScroll: true});
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'u') {
|
||||
this._onClear(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onOptionsUpdated() {
|
||||
await this._display.updateOptions();
|
||||
const query = this._queryInput.value;
|
||||
if (query) {
|
||||
this._display.searchLast(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('display').EventArgument<'optionsUpdated'>} details
|
||||
*/
|
||||
async _onDisplayOptionsUpdated({options}) {
|
||||
this._clipboardMonitorEnabled = options.clipboard.enableSearchPageMonitor;
|
||||
this._updateClipboardMonitorEnabled();
|
||||
this._updateSearchSettings(options);
|
||||
this._queryInput.lang = options.general.language;
|
||||
await this._updateProfileSelect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').ProfileOptions} options
|
||||
*/
|
||||
_updateSearchSettings(options) {
|
||||
const {language, enableWanakana, stickySearchHeader} = options.general;
|
||||
const wanakanaEnabled = language === 'ja' && enableWanakana;
|
||||
this._wanakanaEnableCheckbox.checked = wanakanaEnabled;
|
||||
this._wanakanaSearchOption.style.display = language === 'ja' ? '' : 'none';
|
||||
this._setWanakanaEnabled(wanakanaEnabled);
|
||||
this._setStickyHeaderEnabled(stickySearchHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('display').EventArgument<'contentUpdateStart'>} details
|
||||
*/
|
||||
_onContentUpdateStart({type, query}) {
|
||||
let animate = false;
|
||||
let valid = false;
|
||||
let showBackButton = false;
|
||||
switch (type) {
|
||||
case 'terms':
|
||||
case 'kanji':
|
||||
{
|
||||
const {content, state} = this._display.history;
|
||||
animate = (typeof content === 'object' && content !== null && content.animate === true);
|
||||
showBackButton = (typeof state === 'object' && state !== null && state.cause === 'queryParser');
|
||||
valid = (typeof query === 'string' && query.length > 0);
|
||||
this._display.blurElement(this._queryInput);
|
||||
}
|
||||
break;
|
||||
case 'clear':
|
||||
valid = false;
|
||||
animate = true;
|
||||
query = '';
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof query !== 'string') { query = ''; }
|
||||
|
||||
this._searchBackButton.hidden = !showBackButton;
|
||||
|
||||
if (this._queryInput.value !== query) {
|
||||
this._queryInput.value = query.trimEnd();
|
||||
this._updateSearchHeight(true);
|
||||
}
|
||||
this._setIntroVisible(!valid, animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputEvent} e
|
||||
*/
|
||||
_onSearchInput(e) {
|
||||
this._updateSearchHeight(true);
|
||||
|
||||
const element = /** @type {HTMLTextAreaElement} */ (e.currentTarget);
|
||||
if (this._wanakanaEnabled) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_onSearchKeydown(e) {
|
||||
// Keycode 229 is a special value for events processed by the IME.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event#keydown_events_with_ime
|
||||
if (e.isComposing || e.keyCode === 229) { return; }
|
||||
const {code, key} = e;
|
||||
if (!((code === 'Enter' || key === 'Enter' || code === 'NumpadEnter') && !e.shiftKey)) { return; }
|
||||
|
||||
// Search
|
||||
const element = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
this._display.blurElement(element);
|
||||
this._search(true, 'new', true, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onSearch(e) {
|
||||
e.preventDefault();
|
||||
this._search(true, 'new', true, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onClear(e) {
|
||||
e.preventDefault();
|
||||
this._queryInput.value = '';
|
||||
this._queryInput.focus();
|
||||
this._updateSearchHeight(true);
|
||||
}
|
||||
|
||||
/** */
|
||||
_onSearchBackButtonClick() {
|
||||
this._display.history.back();
|
||||
}
|
||||
|
||||
/** */
|
||||
async _onCopy() {
|
||||
// Ignore copy from search page
|
||||
this._clipboardMonitor.setPreviousText(document.hasFocus() ? await this._clipboardReaderLike.getText(false) : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ClipboardEvent} e
|
||||
*/
|
||||
_onPaste(e) {
|
||||
if (e.target === this._queryInput) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData?.getData('text');
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (this._queryInput.value !== text) {
|
||||
this._queryInput.value = text;
|
||||
this._updateSearchHeight(true);
|
||||
this._search(true, 'new', true, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('application').ApiHandler<'searchDisplayControllerUpdateSearchQuery'>} */
|
||||
_onExternalSearchUpdate({text, animate}) {
|
||||
void this._updateSearchFromClipboard(text, animate, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('clipboard-monitor').Events['change']} event
|
||||
*/
|
||||
_onClipboardMonitorChange({text}) {
|
||||
void this._updateSearchFromClipboard(text, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {boolean} animate
|
||||
* @param {boolean} checkText
|
||||
*/
|
||||
async _updateSearchFromClipboard(text, animate, checkText) {
|
||||
const options = this._display.getOptions();
|
||||
if (options === null) { return; }
|
||||
if (checkText && !await this._display.application.api.isTextLookupWorthy(text, options.general.language)) { return; }
|
||||
const {clipboard: {autoSearchContent, maximumSearchLength}} = options;
|
||||
if (text.length > maximumSearchLength) {
|
||||
text = text.substring(0, maximumSearchLength);
|
||||
}
|
||||
this._queryInput.value = text;
|
||||
this._updateSearchHeight(true);
|
||||
this._search(animate, 'clear', autoSearchContent, ['clipboard']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onWanakanaEnableChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.target);
|
||||
const value = element.checked;
|
||||
this._setWanakanaEnabled(value);
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'general.enableWanakana',
|
||||
value,
|
||||
scope: 'profile',
|
||||
optionsContext: this._display.getOptionsContext(),
|
||||
};
|
||||
void this._display.application.api.modifySettings([modification], 'search');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onClipboardMonitorEnableChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.target);
|
||||
const enabled = element.checked;
|
||||
void this._setClipboardMonitorEnabled(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onStickyHeaderEnableChange(e) {
|
||||
const element = /** @type {HTMLInputElement} */ (e.target);
|
||||
const value = element.checked;
|
||||
this._setStickyHeaderEnabled(value);
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'general.stickySearchHeader',
|
||||
value,
|
||||
scope: 'profile',
|
||||
optionsContext: this._display.getOptionsContext(),
|
||||
};
|
||||
void this._display.application.api.modifySettings([modification], 'search');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} stickySearchHeaderEnabled
|
||||
*/
|
||||
_setStickyHeaderEnabled(stickySearchHeaderEnabled) {
|
||||
this._stickyHeaderEnableCheckbox.checked = stickySearchHeaderEnabled;
|
||||
}
|
||||
|
||||
/** */
|
||||
_onModeChange() {
|
||||
this._updateClipboardMonitorEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
async _onProfileSelectChange(event) {
|
||||
const node = /** @type {HTMLInputElement} */ (event.currentTarget);
|
||||
const value = Number.parseInt(node.value, 10);
|
||||
const optionsFull = await this._display.application.api.optionsGetFull();
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= optionsFull.profiles.length) {
|
||||
await this._setDefaultProfileIndex(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*/
|
||||
async _setDefaultProfileIndex(value) {
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'profileCurrent',
|
||||
value,
|
||||
scope: 'global',
|
||||
optionsContext: null,
|
||||
};
|
||||
await this._display.application.api.modifySettings([modification], 'search');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
_setWanakanaEnabled(enabled) {
|
||||
if (this._queryInputEventsSetup && this._wanakanaEnabled === enabled) { return; }
|
||||
|
||||
const input = this._queryInput;
|
||||
this._queryInputEvents.removeAllEventListeners();
|
||||
this._queryInputEvents.addEventListener(input, 'keydown', this._onSearchKeydown.bind(this), false);
|
||||
|
||||
this._wanakanaEnabled = enabled;
|
||||
|
||||
this._queryInputEvents.addEventListener(input, 'input', this._onSearchInput.bind(this), false);
|
||||
this._queryInputEventsSetup = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} visible
|
||||
* @param {boolean} animate
|
||||
*/
|
||||
_setIntroVisible(visible, animate) {
|
||||
if (this._introVisible === visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._introVisible = visible;
|
||||
|
||||
if (this._introElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._introAnimationTimer !== null) {
|
||||
clearTimeout(this._introAnimationTimer);
|
||||
this._introAnimationTimer = null;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
this._showIntro(animate);
|
||||
} else {
|
||||
this._hideIntro(animate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} animate
|
||||
*/
|
||||
_showIntro(animate) {
|
||||
if (animate) {
|
||||
const duration = 0.4;
|
||||
this._introElement.style.transition = '';
|
||||
this._introElement.style.height = '';
|
||||
const size = this._introElement.getBoundingClientRect();
|
||||
this._introElement.style.height = '0px';
|
||||
this._introElement.style.transition = `height ${duration}s ease-in-out 0s`;
|
||||
window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation
|
||||
this._introElement.style.height = `${size.height}px`;
|
||||
this._introAnimationTimer = setTimeout(() => {
|
||||
this._introElement.style.height = '';
|
||||
this._introAnimationTimer = null;
|
||||
}, duration * 1000);
|
||||
} else {
|
||||
this._introElement.style.transition = '';
|
||||
this._introElement.style.height = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} animate
|
||||
*/
|
||||
_hideIntro(animate) {
|
||||
if (animate) {
|
||||
const duration = 0.4;
|
||||
const size = this._introElement.getBoundingClientRect();
|
||||
this._introElement.style.height = `${size.height}px`;
|
||||
this._introElement.style.transition = `height ${duration}s ease-in-out 0s`;
|
||||
window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation
|
||||
} else {
|
||||
this._introElement.style.transition = '';
|
||||
}
|
||||
this._introElement.style.height = '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
*/
|
||||
async _setClipboardMonitorEnabled(value) {
|
||||
let modify = true;
|
||||
if (value) {
|
||||
value = await this._requestPermissions(['clipboardRead']);
|
||||
modify = value;
|
||||
}
|
||||
|
||||
this._clipboardMonitorEnabled = value;
|
||||
this._updateClipboardMonitorEnabled();
|
||||
|
||||
if (!modify) { return; }
|
||||
|
||||
/** @type {import('settings-modifications').ScopedModificationSet} */
|
||||
const modification = {
|
||||
action: 'set',
|
||||
path: 'clipboard.enableSearchPageMonitor',
|
||||
value,
|
||||
scope: 'profile',
|
||||
optionsContext: this._display.getOptionsContext(),
|
||||
};
|
||||
await this._display.application.api.modifySettings([modification], 'search');
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateClipboardMonitorEnabled() {
|
||||
const enabled = this._clipboardMonitorEnabled;
|
||||
this._clipboardMonitorEnableCheckbox.checked = enabled;
|
||||
if (enabled && this._canEnableClipboardMonitor()) {
|
||||
this._clipboardMonitor.start();
|
||||
} else {
|
||||
this._clipboardMonitor.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_canEnableClipboardMonitor() {
|
||||
switch (this._searchPersistentStateController.mode) {
|
||||
case 'action-popup':
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} permissions
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
_requestPermissions(permissions) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.permissions.request(
|
||||
{permissions},
|
||||
(granted) => {
|
||||
const e = chrome.runtime.lastError;
|
||||
resolve(!e && granted);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} animate
|
||||
* @param {import('display').HistoryMode} historyMode
|
||||
* @param {boolean} lookup
|
||||
* @param {?import('settings').OptionsContextFlag[]} flags
|
||||
*/
|
||||
_search(animate, historyMode, lookup, flags) {
|
||||
this._updateSearchText();
|
||||
|
||||
const query = this._queryInput.value;
|
||||
const depth = this._display.depth;
|
||||
const url = window.location.href;
|
||||
const documentTitle = document.title;
|
||||
/** @type {import('settings').OptionsContext} */
|
||||
const optionsContext = {depth, url};
|
||||
if (flags !== null) {
|
||||
optionsContext.flags = flags;
|
||||
}
|
||||
const {tabId, frameId} = this._display.application;
|
||||
/** @type {import('display').ContentDetails} */
|
||||
const details = {
|
||||
focus: false,
|
||||
historyMode,
|
||||
params: {
|
||||
query,
|
||||
},
|
||||
state: {
|
||||
focusEntry: 0,
|
||||
optionsContext,
|
||||
url,
|
||||
sentence: {text: query, offset: 0},
|
||||
documentTitle,
|
||||
},
|
||||
content: {
|
||||
dictionaryEntries: void 0,
|
||||
animate,
|
||||
contentOrigin: {tabId, frameId},
|
||||
},
|
||||
};
|
||||
if (!lookup) { details.params.lookup = 'false'; }
|
||||
this._display.setContent(details);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} shrink
|
||||
*/
|
||||
_updateSearchHeight(shrink) {
|
||||
const searchTextbox = this._queryInput;
|
||||
const searchItems = [this._queryInput, this._searchButton, this._searchBackButton, this._clearButton];
|
||||
|
||||
if (shrink) {
|
||||
for (const searchButton of searchItems) {
|
||||
searchButton.style.height = '0';
|
||||
}
|
||||
}
|
||||
const {scrollHeight} = searchTextbox;
|
||||
const currentHeight = searchTextbox.getBoundingClientRect().height;
|
||||
if (shrink || scrollHeight >= currentHeight - 1) {
|
||||
for (const searchButton of searchItems) {
|
||||
searchButton.style.height = `${scrollHeight}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_updateSearchText() {
|
||||
if (this._wanakanaEnabled) {
|
||||
// don't use convertToKanaIME since user searching has finalized the text and is no longer composing
|
||||
this._queryInput.value = convertToKana(this._queryInput.value);
|
||||
}
|
||||
this._queryInput.setSelectionRange(this._queryInput.value.length, this._queryInput.value.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?Element} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isElementInput(element) {
|
||||
if (element === null) { return false; }
|
||||
switch (element.tagName.toLowerCase()) {
|
||||
case 'input':
|
||||
case 'textarea':
|
||||
case 'button':
|
||||
case 'select':
|
||||
return true;
|
||||
}
|
||||
return element instanceof HTMLElement && !!element.isContentEditable;
|
||||
}
|
||||
|
||||
/** */
|
||||
async _updateProfileSelect() {
|
||||
const {profiles, profileCurrent} = await this._display.application.api.optionsGetFull();
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const optionGroup = querySelectorNotNull(document, '#profile-select-option-group');
|
||||
while (optionGroup.firstChild) {
|
||||
optionGroup.removeChild(optionGroup.firstChild);
|
||||
}
|
||||
|
||||
this._profileSelectContainer.hidden = profiles.length <= 1;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0, ii = profiles.length; i < ii; ++i) {
|
||||
const {name} = profiles[i];
|
||||
const option = document.createElement('option');
|
||||
option.textContent = name;
|
||||
option.value = `${i}`;
|
||||
fragment.appendChild(option);
|
||||
}
|
||||
optionGroup.textContent = '';
|
||||
optionGroup.appendChild(fragment);
|
||||
this._profileSelect.value = `${profileCurrent}`;
|
||||
}
|
||||
}
|
||||
73
vendor/yomitan/js/display/search-main.js
vendored
Normal file
73
vendor/yomitan/js/display/search-main.js
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 {HotkeyHandler} from '../input/hotkey-handler.js';
|
||||
import {ModalController} from '../pages/settings/modal-controller.js';
|
||||
import {SettingsController} from '../pages/settings/settings-controller.js';
|
||||
import {SettingsDisplayController} from '../pages/settings/settings-display-controller.js';
|
||||
import {DisplayAnki} from './display-anki.js';
|
||||
import {DisplayAudio} from './display-audio.js';
|
||||
import {Display} from './display.js';
|
||||
import {SearchActionPopupController} from './search-action-popup-controller.js';
|
||||
import {SearchDisplayController} from './search-display-controller.js';
|
||||
import {SearchPersistentStateController} from './search-persistent-state-controller.js';
|
||||
|
||||
await Application.main(true, async (application) => {
|
||||
const documentFocusController = new DocumentFocusController('#search-textbox');
|
||||
documentFocusController.prepare();
|
||||
|
||||
const searchPersistentStateController = new SearchPersistentStateController();
|
||||
searchPersistentStateController.prepare();
|
||||
|
||||
const searchActionPopupController = new SearchActionPopupController(searchPersistentStateController);
|
||||
searchActionPopupController.prepare();
|
||||
|
||||
const hotkeyHandler = new HotkeyHandler();
|
||||
hotkeyHandler.prepare(application.crossFrame);
|
||||
|
||||
const display = new Display(application, 'search', documentFocusController, hotkeyHandler);
|
||||
await display.prepare();
|
||||
|
||||
const displayAudio = new DisplayAudio(display);
|
||||
displayAudio.prepare();
|
||||
|
||||
const displayAnki = new DisplayAnki(display, displayAudio);
|
||||
displayAnki.prepare();
|
||||
|
||||
const searchDisplayController = new SearchDisplayController(display, displayAudio, searchPersistentStateController);
|
||||
await searchDisplayController.prepare();
|
||||
|
||||
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;
|
||||
|
||||
documentFocusController.focusElement();
|
||||
|
||||
display.initializeState();
|
||||
|
||||
document.documentElement.dataset.loaded = 'true';
|
||||
});
|
||||
93
vendor/yomitan/js/display/search-persistent-state-controller.js
vendored
Normal file
93
vendor/yomitan/js/display/search-persistent-state-controller.js
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 {EventDispatcher} from '../core/event-dispatcher.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('search-persistent-state-controller').Events>
|
||||
*/
|
||||
export class SearchPersistentStateController extends EventDispatcher {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {import('display').SearchMode} */
|
||||
this._mode = null;
|
||||
}
|
||||
|
||||
/** @type {import('display').SearchMode} */
|
||||
get mode() {
|
||||
return this._mode;
|
||||
}
|
||||
|
||||
set mode(value) {
|
||||
this._setMode(value, true);
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._updateMode();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_updateMode() {
|
||||
let mode = null;
|
||||
try {
|
||||
mode = sessionStorage.getItem('mode');
|
||||
} catch (e) {
|
||||
// Browsers can throw a SecurityError when cookie blocking is enabled.
|
||||
}
|
||||
this._setMode(this._normalizeMode(mode), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('display').SearchMode} mode
|
||||
* @param {boolean} save
|
||||
*/
|
||||
_setMode(mode, save) {
|
||||
if (mode === this._mode) { return; }
|
||||
if (save) {
|
||||
try {
|
||||
if (mode === null) {
|
||||
sessionStorage.removeItem('mode');
|
||||
} else {
|
||||
sessionStorage.setItem('mode', mode);
|
||||
}
|
||||
} catch (e) {
|
||||
// Browsers can throw a SecurityError when cookie blocking is enabled.
|
||||
}
|
||||
}
|
||||
this._mode = mode;
|
||||
document.documentElement.dataset.searchMode = (mode !== null ? mode : '');
|
||||
this.trigger('modeChange', {mode});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?string} mode
|
||||
* @returns {import('display').SearchMode}
|
||||
*/
|
||||
_normalizeMode(mode) {
|
||||
switch (mode) {
|
||||
case 'popup':
|
||||
case 'action-popup':
|
||||
return mode;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
507
vendor/yomitan/js/display/structured-content-generator.js
vendored
Normal file
507
vendor/yomitan/js/display/structured-content-generator.js
vendored
Normal file
@@ -0,0 +1,507 @@
|
||||
/*
|
||||
* 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 {DisplayContentManager} from '../display/display-content-manager.js';
|
||||
import {getLanguageFromText} from '../language/text-utilities.js';
|
||||
import {AnkiTemplateRendererContentManager} from '../templates/anki-template-renderer-content-manager.js';
|
||||
|
||||
export class StructuredContentGenerator {
|
||||
/**
|
||||
* @param {import('./display-content-manager.js').DisplayContentManager|import('../templates/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} contentManager
|
||||
* @param {Document} document
|
||||
* @param {Window} window
|
||||
*/
|
||||
constructor(contentManager, document, window) {
|
||||
/** @type {import('./display-content-manager.js').DisplayContentManager|import('../templates/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} */
|
||||
this._contentManager = contentManager;
|
||||
/** @type {Document} */
|
||||
this._document = document;
|
||||
/** @type {Window} */
|
||||
this._window = window;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
* @param {import('structured-content').Content} content
|
||||
* @param {string} dictionary
|
||||
*/
|
||||
appendStructuredContent(node, content, dictionary) {
|
||||
node.classList.add('structured-content');
|
||||
this._appendStructuredContent(node, content, dictionary, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('structured-content').Content} content
|
||||
* @param {string} dictionary
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
createStructuredContent(content, dictionary) {
|
||||
const node = this._createElement('span', 'structured-content');
|
||||
this._appendStructuredContent(node, content, dictionary, null);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('structured-content').ImageElement|import('dictionary-data').TermGlossaryImage} data
|
||||
* @param {string} dictionary
|
||||
* @returns {HTMLAnchorElement}
|
||||
*/
|
||||
createDefinitionImage(data, dictionary) {
|
||||
const {
|
||||
path,
|
||||
width = 100,
|
||||
height = 100,
|
||||
preferredWidth,
|
||||
preferredHeight,
|
||||
title,
|
||||
pixelated,
|
||||
imageRendering,
|
||||
appearance,
|
||||
background,
|
||||
collapsed,
|
||||
collapsible,
|
||||
verticalAlign,
|
||||
border,
|
||||
borderRadius,
|
||||
sizeUnits,
|
||||
} = data;
|
||||
|
||||
const hasPreferredWidth = (typeof preferredWidth === 'number');
|
||||
const hasPreferredHeight = (typeof preferredHeight === 'number');
|
||||
const invAspectRatio = (
|
||||
hasPreferredWidth && hasPreferredHeight ?
|
||||
preferredHeight / preferredWidth :
|
||||
height / width
|
||||
);
|
||||
const usedWidth = (
|
||||
hasPreferredWidth ?
|
||||
preferredWidth :
|
||||
(hasPreferredHeight ? preferredHeight / invAspectRatio : width)
|
||||
);
|
||||
|
||||
const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-image-link'));
|
||||
node.target = '_blank';
|
||||
node.rel = 'noreferrer noopener';
|
||||
|
||||
const imageContainer = this._createElement('span', 'gloss-image-container');
|
||||
node.appendChild(imageContainer);
|
||||
|
||||
const aspectRatioSizer = this._createElement('span', 'gloss-image-sizer');
|
||||
imageContainer.appendChild(aspectRatioSizer);
|
||||
|
||||
const imageBackground = this._createElement('span', 'gloss-image-background');
|
||||
imageContainer.appendChild(imageBackground);
|
||||
|
||||
const overlay = this._createElement('span', 'gloss-image-container-overlay');
|
||||
imageContainer.appendChild(overlay);
|
||||
|
||||
const linkText = this._createElement('span', 'gloss-image-link-text');
|
||||
linkText.textContent = 'Image';
|
||||
node.appendChild(linkText);
|
||||
|
||||
if (this._contentManager instanceof DisplayContentManager) {
|
||||
node.addEventListener('click', () => {
|
||||
if (this._contentManager instanceof DisplayContentManager) {
|
||||
void this._contentManager.openMediaInTab(path, dictionary, this._window);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
node.dataset.path = path;
|
||||
node.dataset.dictionary = dictionary;
|
||||
node.dataset.imageLoadState = 'not-loaded';
|
||||
node.dataset.hasAspectRatio = 'true';
|
||||
node.dataset.imageRendering = typeof imageRendering === 'string' ? imageRendering : (pixelated ? 'pixelated' : 'auto');
|
||||
node.dataset.appearance = typeof appearance === 'string' ? appearance : 'auto';
|
||||
node.dataset.background = typeof background === 'boolean' ? `${background}` : 'true';
|
||||
node.dataset.collapsed = typeof collapsed === 'boolean' ? `${collapsed}` : 'false';
|
||||
node.dataset.collapsible = typeof collapsible === 'boolean' ? `${collapsible}` : 'true';
|
||||
if (typeof verticalAlign === 'string') {
|
||||
node.dataset.verticalAlign = verticalAlign;
|
||||
}
|
||||
if (typeof sizeUnits === 'string' && (hasPreferredWidth || hasPreferredHeight)) {
|
||||
node.dataset.sizeUnits = sizeUnits;
|
||||
}
|
||||
|
||||
aspectRatioSizer.style.paddingTop = `${invAspectRatio * 100}%`;
|
||||
|
||||
if (typeof border === 'string') { imageContainer.style.border = border; }
|
||||
if (typeof borderRadius === 'string') { imageContainer.style.borderRadius = borderRadius; }
|
||||
imageContainer.style.width = `${usedWidth}em`;
|
||||
if (typeof title === 'string') {
|
||||
imageContainer.title = title;
|
||||
}
|
||||
|
||||
if (this._contentManager !== null) {
|
||||
const image = this._contentManager instanceof DisplayContentManager ?
|
||||
/** @type {HTMLCanvasElement} */ (this._createElement('canvas', 'gloss-image')) :
|
||||
/** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image'));
|
||||
if (sizeUnits === 'em' && (hasPreferredWidth || hasPreferredHeight)) {
|
||||
const emSize = 14; // We could Number.parseFloat(getComputedStyle(document.documentElement).fontSize); here for more accuracy but it would cause a layout and be extremely slow; possible improvement would be to calculate and cache the value
|
||||
const scaleFactor = 2 * this._window.devicePixelRatio;
|
||||
image.style.width = `${usedWidth}em`;
|
||||
image.style.height = `${usedWidth * invAspectRatio}em`;
|
||||
image.width = usedWidth * emSize * scaleFactor;
|
||||
} else {
|
||||
image.width = usedWidth;
|
||||
}
|
||||
image.height = image.width * invAspectRatio;
|
||||
|
||||
// Anki will not render images correctly without specifying to use 100% width and height
|
||||
image.style.width = '100%';
|
||||
image.style.height = '100%';
|
||||
|
||||
imageContainer.appendChild(image);
|
||||
|
||||
if (this._contentManager instanceof DisplayContentManager) {
|
||||
this._contentManager.loadMedia(
|
||||
path,
|
||||
dictionary,
|
||||
(/** @type {HTMLCanvasElement} */(image)).transferControlToOffscreen(),
|
||||
);
|
||||
} else if (this._contentManager instanceof AnkiTemplateRendererContentManager) {
|
||||
this._contentManager.loadMedia(
|
||||
path,
|
||||
dictionary,
|
||||
(url) => {
|
||||
this._setImageData(node, /** @type {HTMLImageElement} */ (image), imageBackground, url, false);
|
||||
},
|
||||
() => {
|
||||
this._setImageData(node, /** @type {HTMLImageElement} */ (image), imageBackground, null, true);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {import('structured-content').Content|undefined} content
|
||||
* @param {string} dictionary
|
||||
* @param {?string} language
|
||||
*/
|
||||
_appendStructuredContent(container, content, dictionary, language) {
|
||||
if (typeof content === 'string') {
|
||||
if (content.length > 0) {
|
||||
container.appendChild(this._createTextNode(content));
|
||||
if (language === null) {
|
||||
const language2 = getLanguageFromText(content, language);
|
||||
if (language2 !== null) {
|
||||
container.lang = language2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!(typeof content === 'object' && content !== null)) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
this._appendStructuredContent(container, item, dictionary, language);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const node = this._createStructuredContentGenericElement(content, dictionary, language);
|
||||
if (node !== null) {
|
||||
container.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
* @param {string} className
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createElement(tagName, className) {
|
||||
const node = this._document.createElement(tagName);
|
||||
node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} data
|
||||
* @returns {Text}
|
||||
*/
|
||||
_createTextNode(data) {
|
||||
return this._document.createTextNode(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {import('structured-content').Data} data
|
||||
*/
|
||||
_setElementDataset(element, data) {
|
||||
for (let [key, value] of Object.entries(data)) {
|
||||
if (key.length > 0) {
|
||||
key = `${key[0].toUpperCase()}${key.substring(1)}`;
|
||||
}
|
||||
key = `sc${key}`;
|
||||
try {
|
||||
element.dataset[key] = value;
|
||||
} catch (e) {
|
||||
// DOMException if key is malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLAnchorElement} node
|
||||
* @param {HTMLImageElement} image
|
||||
* @param {HTMLElement} imageBackground
|
||||
* @param {?string} url
|
||||
* @param {boolean} unloaded
|
||||
*/
|
||||
_setImageData(node, image, imageBackground, url, unloaded) {
|
||||
if (url !== null) {
|
||||
image.src = url;
|
||||
node.href = url;
|
||||
node.dataset.imageLoadState = 'loaded';
|
||||
imageBackground.style.setProperty('--image', `url("${url}")`);
|
||||
} else {
|
||||
image.removeAttribute('src');
|
||||
node.removeAttribute('href');
|
||||
node.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error';
|
||||
imageBackground.style.removeProperty('--image');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('structured-content').Element} content
|
||||
* @param {string} dictionary
|
||||
* @param {?string} language
|
||||
* @returns {?HTMLElement}
|
||||
*/
|
||||
_createStructuredContentGenericElement(content, dictionary, language) {
|
||||
const {tag} = content;
|
||||
switch (tag) {
|
||||
case 'br':
|
||||
return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', false, false);
|
||||
case 'ruby':
|
||||
case 'rt':
|
||||
case 'rp':
|
||||
return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', true, false);
|
||||
case 'table':
|
||||
return this._createStructuredContentTableElement(tag, content, dictionary, language);
|
||||
case 'thead':
|
||||
case 'tbody':
|
||||
case 'tfoot':
|
||||
case 'tr':
|
||||
return this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false);
|
||||
case 'th':
|
||||
case 'td':
|
||||
return this._createStructuredContentElement(tag, content, dictionary, language, 'table-cell', true, true);
|
||||
case 'div':
|
||||
case 'span':
|
||||
case 'ol':
|
||||
case 'ul':
|
||||
case 'li':
|
||||
case 'details':
|
||||
case 'summary':
|
||||
return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', true, true);
|
||||
case 'img':
|
||||
return this.createDefinitionImage(content, dictionary);
|
||||
case 'a':
|
||||
return this._createLinkElement(content, dictionary, language);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tag
|
||||
* @param {import('structured-content').UnstyledElement} content
|
||||
* @param {string} dictionary
|
||||
* @param {?string} language
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createStructuredContentTableElement(tag, content, dictionary, language) {
|
||||
const container = this._createElement('div', 'gloss-sc-table-container');
|
||||
const table = this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false);
|
||||
container.appendChild(table);
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tag
|
||||
* @param {import('structured-content').StyledElement|import('structured-content').UnstyledElement|import('structured-content').TableElement|import('structured-content').LineBreak} content
|
||||
* @param {string} dictionary
|
||||
* @param {?string} language
|
||||
* @param {'simple'|'table'|'table-cell'} type
|
||||
* @param {boolean} hasChildren
|
||||
* @param {boolean} hasStyle
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createStructuredContentElement(tag, content, dictionary, language, type, hasChildren, hasStyle) {
|
||||
const node = this._createElement(tag, `gloss-sc-${tag}`);
|
||||
const {data, lang} = content;
|
||||
if (typeof data === 'object' && data !== null) { this._setElementDataset(node, data); }
|
||||
if (typeof lang === 'string') {
|
||||
node.lang = lang;
|
||||
language = lang;
|
||||
}
|
||||
switch (type) {
|
||||
case 'table-cell':
|
||||
{
|
||||
const cell = /** @type {HTMLTableCellElement} */ (node);
|
||||
const {colSpan, rowSpan} = /** @type {import('structured-content').TableElement} */ (content);
|
||||
if (typeof colSpan === 'number') { cell.colSpan = colSpan; }
|
||||
if (typeof rowSpan === 'number') { cell.rowSpan = rowSpan; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (hasStyle) {
|
||||
const {style, title, open} = /** @type {import('structured-content').StyledElement} */ (content);
|
||||
if (typeof style === 'object' && style !== null) {
|
||||
this._setStructuredContentElementStyle(node, style);
|
||||
}
|
||||
if (typeof title === 'string') { node.title = title; }
|
||||
if (typeof open === 'boolean' && open) { node.setAttribute('open', ''); }
|
||||
}
|
||||
if (hasChildren) {
|
||||
this._appendStructuredContent(node, content.content, dictionary, language);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
* @param {import('structured-content').StructuredContentStyle} contentStyle
|
||||
*/
|
||||
_setStructuredContentElementStyle(node, contentStyle) {
|
||||
const {style} = node;
|
||||
const {
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
color,
|
||||
background,
|
||||
backgroundColor,
|
||||
textDecorationLine,
|
||||
textDecorationStyle,
|
||||
textDecorationColor,
|
||||
borderColor,
|
||||
borderStyle,
|
||||
borderRadius,
|
||||
borderWidth,
|
||||
clipPath,
|
||||
verticalAlign,
|
||||
textAlign,
|
||||
textEmphasis,
|
||||
textShadow,
|
||||
margin,
|
||||
marginTop,
|
||||
marginLeft,
|
||||
marginRight,
|
||||
marginBottom,
|
||||
padding,
|
||||
paddingTop,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
paddingBottom,
|
||||
wordBreak,
|
||||
whiteSpace,
|
||||
cursor,
|
||||
listStyleType,
|
||||
} = contentStyle;
|
||||
if (typeof fontStyle === 'string') { style.fontStyle = fontStyle; }
|
||||
if (typeof fontWeight === 'string') { style.fontWeight = fontWeight; }
|
||||
if (typeof fontSize === 'string') { style.fontSize = fontSize; }
|
||||
if (typeof color === 'string') { style.color = color; }
|
||||
if (typeof background === 'string') { style.background = background; }
|
||||
if (typeof backgroundColor === 'string') { style.backgroundColor = backgroundColor; }
|
||||
if (typeof verticalAlign === 'string') { style.verticalAlign = verticalAlign; }
|
||||
if (typeof textAlign === 'string') { style.textAlign = textAlign; }
|
||||
if (typeof textEmphasis === 'string') { style.textEmphasis = textEmphasis; }
|
||||
if (typeof textShadow === 'string') { style.textShadow = textShadow; }
|
||||
if (typeof textDecorationLine === 'string') {
|
||||
style.textDecoration = textDecorationLine;
|
||||
} else if (Array.isArray(textDecorationLine)) {
|
||||
style.textDecoration = textDecorationLine.join(' ');
|
||||
}
|
||||
if (typeof textDecorationStyle === 'string') {
|
||||
style.textDecorationStyle = textDecorationStyle;
|
||||
}
|
||||
if (typeof textDecorationColor === 'string') {
|
||||
style.textDecorationColor = textDecorationColor;
|
||||
}
|
||||
if (typeof borderColor === 'string') { style.borderColor = borderColor; }
|
||||
if (typeof borderStyle === 'string') { style.borderStyle = borderStyle; }
|
||||
if (typeof borderRadius === 'string') { style.borderRadius = borderRadius; }
|
||||
if (typeof borderWidth === 'string') { style.borderWidth = borderWidth; }
|
||||
if (typeof clipPath === 'string') { style.clipPath = clipPath; }
|
||||
if (typeof margin === 'string') { style.margin = margin; }
|
||||
if (typeof marginTop === 'number') { style.marginTop = `${marginTop}em`; }
|
||||
if (typeof marginTop === 'string') { style.marginTop = marginTop; }
|
||||
if (typeof marginLeft === 'number') { style.marginLeft = `${marginLeft}em`; }
|
||||
if (typeof marginLeft === 'string') { style.marginLeft = marginLeft; }
|
||||
if (typeof marginRight === 'number') { style.marginRight = `${marginRight}em`; }
|
||||
if (typeof marginRight === 'string') { style.marginRight = marginRight; }
|
||||
if (typeof marginBottom === 'number') { style.marginBottom = `${marginBottom}em`; }
|
||||
if (typeof marginBottom === 'string') { style.marginBottom = marginBottom; }
|
||||
if (typeof padding === 'string') { style.padding = padding; }
|
||||
if (typeof paddingTop === 'string') { style.paddingTop = paddingTop; }
|
||||
if (typeof paddingLeft === 'string') { style.paddingLeft = paddingLeft; }
|
||||
if (typeof paddingRight === 'string') { style.paddingRight = paddingRight; }
|
||||
if (typeof paddingBottom === 'string') { style.paddingBottom = paddingBottom; }
|
||||
if (typeof wordBreak === 'string') { style.wordBreak = wordBreak; }
|
||||
if (typeof whiteSpace === 'string') { style.whiteSpace = whiteSpace; }
|
||||
if (typeof cursor === 'string') { style.cursor = cursor; }
|
||||
if (typeof listStyleType === 'string') { style.listStyleType = listStyleType; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('structured-content').LinkElement} content
|
||||
* @param {string} dictionary
|
||||
* @param {?string} language
|
||||
* @returns {HTMLAnchorElement}
|
||||
*/
|
||||
_createLinkElement(content, dictionary, language) {
|
||||
let {href} = content;
|
||||
const internal = href.startsWith('?');
|
||||
if (internal) {
|
||||
href = `${location.protocol}//${location.host}/search.html${href.length > 1 ? href : ''}`;
|
||||
}
|
||||
|
||||
const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-link'));
|
||||
node.dataset.external = `${!internal}`;
|
||||
|
||||
const text = this._createElement('span', 'gloss-link-text');
|
||||
node.appendChild(text);
|
||||
|
||||
const {lang} = content;
|
||||
if (typeof lang === 'string') {
|
||||
node.lang = lang;
|
||||
language = lang;
|
||||
}
|
||||
|
||||
this._appendStructuredContent(text, content.content, dictionary, language);
|
||||
|
||||
if (!internal) {
|
||||
const icon = this._createElement('span', 'gloss-link-external-icon icon');
|
||||
icon.dataset.icon = 'external-link';
|
||||
node.appendChild(icon);
|
||||
}
|
||||
|
||||
this._contentManager.prepareLink(node, href, internal);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
203
vendor/yomitan/js/dom/css-style-applier.js
vendored
Normal file
203
vendor/yomitan/js/dom/css-style-applier.js
vendored
Normal file
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* 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 {readResponseJson} from '../core/json.js';
|
||||
import {log} from '../core/log.js';
|
||||
import {toError} from '../core/to-error.js';
|
||||
|
||||
/**
|
||||
* This class is used to apply CSS styles to elements using a consistent method
|
||||
* that is the same across different browsers.
|
||||
*/
|
||||
export class CssStyleApplier {
|
||||
/**
|
||||
* Creates a new instance of the class.
|
||||
* @param {string} styleDataUrl The local URL to the JSON file continaing the style rules.
|
||||
* The style rules should follow the format of `CssStyleApplierRawStyleData`.
|
||||
*/
|
||||
constructor(styleDataUrl) {
|
||||
/** @type {string} */
|
||||
this._styleDataUrl = styleDataUrl;
|
||||
/** @type {import('css-style-applier').CssRule[]} */
|
||||
this._styleData = [];
|
||||
/** @type {Map<string, import('css-style-applier').CssRule[]>} */
|
||||
this._cachedRules = new Map();
|
||||
/** @type {RegExp} */
|
||||
this._patternHtmlWhitespace = /[\t\r\n\f ]+/g;
|
||||
/** @type {RegExp} */
|
||||
this._patternClassNameCharacter = /[0-9a-zA-Z-_]/;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the data file for use.
|
||||
*/
|
||||
async prepare() {
|
||||
/** @type {import('css-style-applier').RawStyleData} */
|
||||
let rawData = [];
|
||||
try {
|
||||
rawData = await this._fetchJsonAsset(this._styleDataUrl);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
const styleData = this._styleData;
|
||||
styleData.length = 0;
|
||||
for (const {selectors, styles} of rawData) {
|
||||
const selectors2 = selectors.join(',');
|
||||
const styles2 = [];
|
||||
for (const [property, value] of styles) {
|
||||
styles2.push({property, value});
|
||||
}
|
||||
styleData.push({
|
||||
selectors: selectors2,
|
||||
styles: styles2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies CSS styles directly to the "style" attribute using the "class" attribute.
|
||||
* This only works for elements with a single class.
|
||||
* @param {Iterable<HTMLElement>} elements An iterable collection of HTMLElement objects.
|
||||
*/
|
||||
applyClassStyles(elements) {
|
||||
const elementStyles = [];
|
||||
for (const element of elements) {
|
||||
const className = element.getAttribute('class');
|
||||
if (className === null || className.length === 0) { continue; }
|
||||
let cssTextNew = '';
|
||||
for (const {selectors, styles} of this._getCandidateCssRulesForClass(className)) {
|
||||
try { // `css-select` used by `linkedom` in the Yomitan API does not support some pseudo elements and may error
|
||||
if (!element.matches(selectors)) { continue; }
|
||||
} catch (e) {
|
||||
log.log('Failed to match css selectors: ' + selectors + '\n' + toError(e).message);
|
||||
continue;
|
||||
}
|
||||
cssTextNew += this._getCssText(styles);
|
||||
}
|
||||
cssTextNew += element.style.cssText;
|
||||
elementStyles.push({element, style: cssTextNew});
|
||||
}
|
||||
for (const {element, style} of elementStyles) {
|
||||
element.removeAttribute('class');
|
||||
if (style.length > 0) {
|
||||
element.setAttribute('style', style);
|
||||
} else {
|
||||
element.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* Fetches and parses a JSON file.
|
||||
* @template [T=unknown]
|
||||
* @param {string} url The URL to the file.
|
||||
* @returns {Promise<T>} A JSON object.
|
||||
* @throws {Error} An error is thrown if the fetch fails.
|
||||
*/
|
||||
async _fetchJsonAsset(url) {
|
||||
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}`);
|
||||
}
|
||||
return await readResponseJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array of candidate CSS rules which might match a specific class.
|
||||
* @param {string} className A whitespace-separated list of classes.
|
||||
* @returns {import('css-style-applier').CssRule[]} An array of candidate CSS rules.
|
||||
*/
|
||||
_getCandidateCssRulesForClass(className) {
|
||||
let rules = this._cachedRules.get(className);
|
||||
if (typeof rules !== 'undefined') { return rules; }
|
||||
|
||||
rules = [];
|
||||
this._cachedRules.set(className, rules);
|
||||
|
||||
const classList = this._getTokens(className);
|
||||
for (const {selectors, styles} of this._styleData) {
|
||||
if (!this._selectorMightMatch(selectors, classList)) { continue; }
|
||||
rules.push({selectors, styles});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of CSS rules to a CSS string.
|
||||
* @param {import('css-style-applier').CssDeclaration[]} styles An array of CSS rules.
|
||||
* @returns {string} The CSS string.
|
||||
*/
|
||||
_getCssText(styles) {
|
||||
let cssText = '';
|
||||
for (const {property, value} of styles) {
|
||||
cssText += `${property}:${value};`;
|
||||
}
|
||||
return cssText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not a CSS string might match at least one class in a list.
|
||||
* @param {string} selectors A CSS selector string.
|
||||
* @param {string[]} classList An array of CSS classes.
|
||||
* @returns {boolean} `true` if the selector string might match one of the classes in `classList`, false otherwise.
|
||||
*/
|
||||
_selectorMightMatch(selectors, classList) {
|
||||
const pattern = this._patternClassNameCharacter;
|
||||
for (const item of classList) {
|
||||
const prefixedItem = `.${item}`;
|
||||
let start = 0;
|
||||
while (true) {
|
||||
const index = selectors.indexOf(prefixedItem, start);
|
||||
if (index < 0) { break; }
|
||||
start = index + prefixedItem.length;
|
||||
if (start >= selectors.length || !pattern.test(selectors[start])) { return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the whitespace-delimited tokens from a string.
|
||||
* @param {string} tokenListString The string to parse.
|
||||
* @returns {string[]} An array of tokens.
|
||||
*/
|
||||
_getTokens(tokenListString) {
|
||||
let start = 0;
|
||||
const pattern = this._patternHtmlWhitespace;
|
||||
pattern.lastIndex = 0;
|
||||
const result = [];
|
||||
while (true) {
|
||||
const match = pattern.exec(tokenListString);
|
||||
const end = match === null ? tokenListString.length : match.index;
|
||||
if (end > start) { result.push(tokenListString.substring(start, end)); }
|
||||
if (match === null) { return result; }
|
||||
start = end + match[0].length;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
vendor/yomitan/js/dom/document-focus-controller.js
vendored
Normal file
151
vendor/yomitan/js/dom/document-focus-controller.js
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This class is used to control the document focus when a non-body element contains the main scrollbar.
|
||||
* Web browsers will not automatically focus a custom element with the scrollbar on load, which results in
|
||||
* keyboard shortcuts (e.g. arrow keys) not controlling page scroll. Instead, this class will manually
|
||||
* focus a dummy element inside the main content, which gives keyboard scroll focus to that element.
|
||||
*/
|
||||
export class DocumentFocusController {
|
||||
/**
|
||||
* Creates a new instance of the class.
|
||||
* @param {?string} autofocusElementSelector A selector string which can be used to specify an element which
|
||||
* should be automatically focused on prepare.
|
||||
*/
|
||||
constructor(autofocusElementSelector = null) {
|
||||
/** @type {?HTMLElement} */
|
||||
this._autofocusElement = (autofocusElementSelector !== null ? document.querySelector(autofocusElementSelector) : null);
|
||||
/** @type {?HTMLElement} */
|
||||
this._contentScrollFocusElement = document.querySelector('#content-scroll-focus');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the instance.
|
||||
*/
|
||||
prepare() {
|
||||
window.addEventListener('focus', this._onWindowFocus.bind(this), false);
|
||||
this._updateFocusedElement(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes focus from a given element.
|
||||
* @param {HTMLElement} element The element to remove focus from.
|
||||
*/
|
||||
blurElement(element) {
|
||||
if (document.activeElement !== element) { return; }
|
||||
element.blur();
|
||||
this._updateFocusedElement(false);
|
||||
}
|
||||
|
||||
/** */
|
||||
focusElement() {
|
||||
if (this._autofocusElement !== null && document.activeElement !== this._autofocusElement) {
|
||||
this._autofocusElement.focus({preventScroll: true});
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onWindowFocus() {
|
||||
this._updateFocusedElement(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} force
|
||||
*/
|
||||
_updateFocusedElement(force) {
|
||||
const target = this._contentScrollFocusElement;
|
||||
if (target === null) { return; }
|
||||
|
||||
const {activeElement} = document;
|
||||
if (
|
||||
force ||
|
||||
activeElement === null ||
|
||||
activeElement === document.documentElement ||
|
||||
activeElement === document.body
|
||||
) {
|
||||
// Get selection
|
||||
const selection = window.getSelection();
|
||||
if (selection === null) { return; }
|
||||
const selectionRanges1 = this._getSelectionRanges(selection);
|
||||
|
||||
// Note: This function will cause any selected text to be deselected on Firefox.
|
||||
target.focus({preventScroll: true});
|
||||
|
||||
// Restore selection
|
||||
const selectionRanges2 = this._getSelectionRanges(selection);
|
||||
if (!this._areRangesSame(selectionRanges1, selectionRanges2)) {
|
||||
this._setSelectionRanges(selection, selectionRanges1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Selection} selection
|
||||
* @returns {Range[]}
|
||||
*/
|
||||
_getSelectionRanges(selection) {
|
||||
const ranges = [];
|
||||
for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
|
||||
ranges.push(selection.getRangeAt(i));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Selection} selection
|
||||
* @param {Range[]} ranges
|
||||
*/
|
||||
_setSelectionRanges(selection, ranges) {
|
||||
selection.removeAllRanges();
|
||||
for (const range of ranges) {
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Range[]} ranges1
|
||||
* @param {Range[]} ranges2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_areRangesSame(ranges1, ranges2) {
|
||||
const ii = ranges1.length;
|
||||
if (ii !== ranges2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ii; ++i) {
|
||||
const range1 = ranges1[i];
|
||||
const range2 = ranges2[i];
|
||||
try {
|
||||
if (
|
||||
range1.compareBoundaryPoints(Range.START_TO_START, range2) !== 0 ||
|
||||
range1.compareBoundaryPoints(Range.END_TO_END, range2) !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
503
vendor/yomitan/js/dom/document-util.js
vendored
Normal file
503
vendor/yomitan/js/dom/document-util.js
vendored
Normal file
@@ -0,0 +1,503 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2016-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/>.
|
||||
*/
|
||||
/**
|
||||
* This variable is stateful, but it is only used to do feature detection,
|
||||
* and its value should be constant for the lifetime of the extension.
|
||||
* @type {?boolean}
|
||||
*/
|
||||
let cssZoomSupported = null;
|
||||
|
||||
/** @type {Set<?string>} */
|
||||
const FIREFOX_RECT_EXCLUDED_LANGUAGES = new Set(['th']);
|
||||
|
||||
/**
|
||||
* Computes the scaling adjustment that is necessary for client space coordinates based on the
|
||||
* CSS zoom level.
|
||||
* @param {?Node} node A node in the document.
|
||||
* @returns {number} The scaling factor.
|
||||
*/
|
||||
export function computeZoomScale(node) {
|
||||
if (cssZoomSupported === null) {
|
||||
cssZoomSupported = computeCssZoomSupported();
|
||||
}
|
||||
if (!cssZoomSupported) { return 1; }
|
||||
// documentElement must be excluded because the computer style of its zoom property is inconsistent.
|
||||
// * If CSS `:root{zoom:X;}` is specified, the computed zoom will always report `X`.
|
||||
// * If CSS `:root{zoom:X;}` is not specified, the computed zoom report the browser's zoom level.
|
||||
// Therefor, if CSS root zoom is specified as a value other than 1, the adjusted {x, y} values
|
||||
// would be incorrect, which is not new behaviour.
|
||||
let scale = 1;
|
||||
const {ELEMENT_NODE, DOCUMENT_FRAGMENT_NODE} = Node;
|
||||
const {documentElement} = document;
|
||||
for (; node !== null && node !== documentElement; node = node.parentNode) {
|
||||
const {nodeType} = node;
|
||||
if (nodeType === DOCUMENT_FRAGMENT_NODE) {
|
||||
const {host} = /** @type {ShadowRoot} */ (node);
|
||||
if (typeof host !== 'undefined') {
|
||||
node = host;
|
||||
}
|
||||
continue;
|
||||
} else if (nodeType !== ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
const zoomString = getComputedStyle(/** @type {HTMLElement} */ (node)).getPropertyValue('zoom');
|
||||
if (typeof zoomString !== 'string' || zoomString.length === 0) { continue; }
|
||||
const zoom = Number.parseFloat(zoomString);
|
||||
if (!Number.isFinite(zoom) || zoom === 0) { continue; }
|
||||
scale *= zoom;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a rect based on the CSS zoom scaling for a given node.
|
||||
* @param {DOMRect} rect The rect to convert.
|
||||
* @param {Node} node The node to compute the zoom from.
|
||||
* @returns {DOMRect} The updated rect, or the same rect if no change is needed.
|
||||
*/
|
||||
export function convertRectZoomCoordinates(rect, node) {
|
||||
const scale = computeZoomScale(node);
|
||||
return (scale === 1 ? rect : new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts multiple rects based on the CSS zoom scaling for a given node.
|
||||
* @param {DOMRect[]|DOMRectList} rects The rects to convert.
|
||||
* @param {Node} node The node to compute the zoom from.
|
||||
* @returns {DOMRect[]} The updated rects, or the same rects array if no change is needed.
|
||||
*/
|
||||
export function convertMultipleRectZoomCoordinates(rects, node) {
|
||||
const scale = computeZoomScale(node);
|
||||
if (scale === 1) { return [...rects]; }
|
||||
const results = [];
|
||||
for (const rect of rects) {
|
||||
results.push(new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given point is contained within a rect.
|
||||
* @param {number} x The horizontal coordinate.
|
||||
* @param {number} y The vertical coordinate.
|
||||
* @param {DOMRect} rect The rect to check.
|
||||
* @returns {boolean} `true` if the point is inside the rect, `false` otherwise.
|
||||
*/
|
||||
export function isPointInRect(x, y, rect) {
|
||||
return (
|
||||
x >= rect.left && x < rect.right &&
|
||||
y >= rect.top && y < rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given point is contained within any rect in a list.
|
||||
* @param {number} x The horizontal coordinate.
|
||||
* @param {number} y The vertical coordinate.
|
||||
* @param {DOMRect[]|DOMRectList} rects The rect to check.
|
||||
* @param {?string} language
|
||||
* @returns {boolean} `true` if the point is inside any of the rects, `false` otherwise.
|
||||
*/
|
||||
export function isPointInAnyRect(x, y, rects, language) {
|
||||
// Always return true for Firefox due to inconsistencies with Range.getClientRects() implementation from unclear W3C spec
|
||||
// https://drafts.csswg.org/cssom-view/#dom-range-getclientrects
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=816238
|
||||
// Firefox returns only the first level nodes, Chromium returns every text node
|
||||
// This only affects specific languages
|
||||
if (typeof browser !== 'undefined' && FIREFOX_RECT_EXCLUDED_LANGUAGES.has(language)) {
|
||||
return true;
|
||||
}
|
||||
for (const rect of rects) {
|
||||
if (isPointInRect(x, y, rect)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given point is contained within a selection range.
|
||||
* @param {number} x The horizontal coordinate.
|
||||
* @param {number} y The vertical coordinate.
|
||||
* @param {Selection} selection The selection to check.
|
||||
* @param {string} language
|
||||
* @returns {boolean} `true` if the point is inside the selection, `false` otherwise.
|
||||
*/
|
||||
export function isPointInSelection(x, y, selection, language) {
|
||||
for (let i = 0; i < selection.rangeCount; ++i) {
|
||||
const range = selection.getRangeAt(i);
|
||||
if (isPointInAnyRect(x, y, range.getClientRects(), language)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array of the active modifier keys.
|
||||
* @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check.
|
||||
* @returns {import('input').ModifierKey[]} An array of modifiers.
|
||||
*/
|
||||
export function getActiveModifiers(event) {
|
||||
/** @type {import('input').ModifierKey[]} */
|
||||
const modifiers = [];
|
||||
if (event.altKey) { modifiers.push('alt'); }
|
||||
if (event.ctrlKey) { modifiers.push('ctrl'); }
|
||||
if (event.metaKey) { modifiers.push('meta'); }
|
||||
if (event.shiftKey) { modifiers.push('shift'); }
|
||||
|
||||
// For KeyboardEvent, when modifiers are pressed on Firefox without any other keys, the keydown event does not always contain the last pressed modifier as event.{modifier}
|
||||
// This occurs when the focus is in a textarea element, an input element, or when the raw keycode is not a modifier but the virtual keycode is (this often occurs due to OS level keyboard remapping)
|
||||
// Chrome and Firefox (outside of textareas, inputs, and virtual keycodes) do report the modifier in both the event.{modifier} and the event.code
|
||||
// We must check if the modifier has already been added to not duplicate it
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if ((event.code === 'AltLeft' || event.code === 'AltRight' || event.key === 'Alt') && !modifiers.includes('alt')) { modifiers.push('alt'); }
|
||||
if ((event.code === 'ControlLeft' || event.code === 'ControlRight' || event.key === 'Control') && !modifiers.includes('ctrl')) { modifiers.push('ctrl'); }
|
||||
if ((event.code === 'MetaLeft' || event.code === 'MetaRight' || event.key === 'Meta') && !modifiers.includes('meta')) { modifiers.push('meta'); }
|
||||
if ((event.code === 'ShiftLeft' || event.code === 'ShiftRight' || event.key === 'Shift') && !modifiers.includes('shift')) { modifiers.push('shift'); }
|
||||
}
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array of the active modifier keys and buttons.
|
||||
* @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check.
|
||||
* @returns {import('input').Modifier[]} An array of modifiers and buttons.
|
||||
*/
|
||||
export function getActiveModifiersAndButtons(event) {
|
||||
/** @type {import('input').Modifier[]} */
|
||||
const modifiers = getActiveModifiers(event);
|
||||
if (event instanceof MouseEvent) {
|
||||
getActiveButtonsInternal(event, modifiers);
|
||||
}
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array of the active buttons.
|
||||
* @param {MouseEvent} event The event to check.
|
||||
* @returns {import('input').ModifierMouseButton[]} An array of modifiers and buttons.
|
||||
*/
|
||||
export function getActiveButtons(event) {
|
||||
/** @type {import('input').ModifierMouseButton[]} */
|
||||
const buttons = [];
|
||||
getActiveButtonsInternal(event, buttons);
|
||||
return buttons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a fullscreen change event listener. This function handles all of the browser-specific variants.
|
||||
* @param {EventListener} onFullscreenChanged The event callback.
|
||||
* @param {?import('../core/event-listener-collection.js').EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to.
|
||||
*/
|
||||
export function addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection = null) {
|
||||
const target = document;
|
||||
const options = false;
|
||||
const fullscreenEventNames = [
|
||||
'fullscreenchange',
|
||||
'MSFullscreenChange',
|
||||
'mozfullscreenchange',
|
||||
'webkitfullscreenchange',
|
||||
];
|
||||
for (const eventName of fullscreenEventNames) {
|
||||
if (eventListenerCollection === null) {
|
||||
target.addEventListener(eventName, onFullscreenChanged, options);
|
||||
} else {
|
||||
eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current fullscreen element. This function handles all of the browser-specific variants.
|
||||
* @returns {?Element} The current fullscreen element, or `null` if the window is not fullscreen.
|
||||
*/
|
||||
export function getFullscreenElement() {
|
||||
return (
|
||||
document.fullscreenElement ||
|
||||
// @ts-expect-error - vendor prefix
|
||||
document.msFullscreenElement ||
|
||||
// @ts-expect-error - vendor prefix
|
||||
document.mozFullScreenElement ||
|
||||
// @ts-expect-error - vendor prefix
|
||||
document.webkitFullscreenElement ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all of the nodes within a `Range`.
|
||||
* @param {Range} range The range to check.
|
||||
* @returns {Node[]} The list of nodes.
|
||||
*/
|
||||
export function getNodesInRange(range) {
|
||||
const end = range.endContainer;
|
||||
const nodes = [];
|
||||
for (let node = /** @type {?Node} */ (range.startContainer); node !== null; node = getNextNode(node)) {
|
||||
nodes.push(node);
|
||||
if (node === end) { break; }
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next node after a specified node. This traverses the DOM in its logical order.
|
||||
* @param {Node} node The node to start at.
|
||||
* @returns {?Node} The next node, or `null` if there is no next node.
|
||||
*/
|
||||
export function getNextNode(node) {
|
||||
let next = /** @type {?Node} */ (node.firstChild);
|
||||
if (next === null) {
|
||||
while (true) {
|
||||
next = node.nextSibling;
|
||||
if (next !== null) { break; }
|
||||
|
||||
next = node.parentNode;
|
||||
if (next === null) { break; }
|
||||
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether any node in a list of nodes matches a selector.
|
||||
* @param {Node[]} nodes The list of ndoes to check.
|
||||
* @param {string} selector The selector to test.
|
||||
* @returns {boolean} `true` if any element node matches the selector, `false` otherwise.
|
||||
*/
|
||||
export function anyNodeMatchesSelector(nodes, selector) {
|
||||
const ELEMENT_NODE = Node.ELEMENT_NODE;
|
||||
// This is a rather ugly way of getting the "node" variable to be a nullable
|
||||
for (let node of /** @type {(?Node)[]} */ (nodes)) {
|
||||
while (node !== null) {
|
||||
if (node.nodeType !== ELEMENT_NODE) {
|
||||
node = node.parentNode;
|
||||
continue;
|
||||
}
|
||||
if (/** @type {HTMLElement} */ (node).matches(selector)) { return true; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether every node in a list of nodes matches a selector.
|
||||
* @param {Node[]} nodes The list of ndoes to check.
|
||||
* @param {string} selector The selector to test.
|
||||
* @returns {boolean} `true` if every element node matches the selector, `false` otherwise.
|
||||
*/
|
||||
export function everyNodeMatchesSelector(nodes, selector) {
|
||||
const ELEMENT_NODE = Node.ELEMENT_NODE;
|
||||
// This is a rather ugly way of getting the "node" variable to be a nullable
|
||||
for (let node of /** @type {(?Node)[]} */ (nodes)) {
|
||||
while (true) {
|
||||
if (node === null) { return false; }
|
||||
if (node.nodeType === ELEMENT_NODE && /** @type {HTMLElement} */ (node).matches(selector)) { break; }
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the meta key is supported in the browser on the specified operating system.
|
||||
* @param {string} os The operating system to check.
|
||||
* @param {string} browser The browser to check.
|
||||
* @returns {boolean} `true` if supported, `false` otherwise.
|
||||
*/
|
||||
export function isMetaKeySupported(os, browser) {
|
||||
return !(browser === 'firefox' || browser === 'firefox-mobile') || os === 'mac';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an element on the page that can accept input is focused.
|
||||
* @returns {boolean} `true` if an input element is focused, `false` otherwise.
|
||||
*/
|
||||
export function isInputElementFocused() {
|
||||
const element = document.activeElement;
|
||||
if (element === null) { return false; }
|
||||
const type = element.nodeName.toUpperCase();
|
||||
switch (type) {
|
||||
case 'INPUT':
|
||||
case 'TEXTAREA':
|
||||
case 'SELECT':
|
||||
return true;
|
||||
default:
|
||||
return element instanceof HTMLElement && element.isContentEditable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offsets an array of DOMRects by a given amount.
|
||||
* @param {DOMRect[]} rects The DOMRects to offset.
|
||||
* @param {number} x The horizontal offset amount.
|
||||
* @param {number} y The vertical offset amount.
|
||||
* @returns {DOMRect[]} The DOMRects with the offset applied.
|
||||
*/
|
||||
export function offsetDOMRects(rects, x, y) {
|
||||
const results = [];
|
||||
for (const rect of rects) {
|
||||
results.push(new DOMRect(rect.left + x, rect.top + y, rect.width, rect.height));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent writing mode of an element.
|
||||
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode.
|
||||
* @param {?Element} element The HTML element to check.
|
||||
* @returns {import('document-util').NormalizedWritingMode} The writing mode.
|
||||
*/
|
||||
export function getElementWritingMode(element) {
|
||||
if (element !== null) {
|
||||
const {writingMode} = getComputedStyle(element);
|
||||
if (typeof writingMode === 'string') {
|
||||
return normalizeWritingMode(writingMode);
|
||||
}
|
||||
}
|
||||
return 'horizontal-tb';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a CSS writing mode value by converting non-standard and deprecated values
|
||||
* into their corresponding standard vaules.
|
||||
* @param {string} writingMode The writing mode to normalize.
|
||||
* @returns {import('document-util').NormalizedWritingMode} The normalized writing mode.
|
||||
*/
|
||||
export function normalizeWritingMode(writingMode) {
|
||||
switch (writingMode) {
|
||||
case 'tb':
|
||||
return 'vertical-lr';
|
||||
case 'tb-rl':
|
||||
return 'vertical-rl';
|
||||
case 'horizontal-tb':
|
||||
case 'vertical-rl':
|
||||
case 'vertical-lr':
|
||||
case 'sideways-rl':
|
||||
case 'sideways-lr':
|
||||
return writingMode;
|
||||
default: // 'lr', 'lr-tb', 'rl'
|
||||
return 'horizontal-tb';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a value from an element to a number.
|
||||
* @param {string} valueString A string representation of a number.
|
||||
* @param {import('document-util').ToNumberConstraints} constraints An object which might contain `min`, `max`, and `step` fields which are used to constrain the value.
|
||||
* @returns {number} The parsed and constrained number.
|
||||
*/
|
||||
export function convertElementValueToNumber(valueString, constraints) {
|
||||
let value = Number.parseFloat(valueString);
|
||||
if (!Number.isFinite(value)) { value = 0; }
|
||||
|
||||
const min = convertToNumberOrNull(constraints.min);
|
||||
const max = convertToNumberOrNull(constraints.max);
|
||||
const step = convertToNumberOrNull(constraints.step);
|
||||
if (typeof min === 'number') { value = Math.max(value, min); }
|
||||
if (typeof max === 'number') { value = Math.min(value, max); }
|
||||
if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; }
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {?import('input').Modifier}
|
||||
*/
|
||||
export function normalizeModifier(value) {
|
||||
switch (value) {
|
||||
case 'alt':
|
||||
case 'ctrl':
|
||||
case 'meta':
|
||||
case 'shift':
|
||||
case 'mouse0':
|
||||
case 'mouse1':
|
||||
case 'mouse2':
|
||||
case 'mouse3':
|
||||
case 'mouse4':
|
||||
case 'mouse5':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {?import('input').ModifierKey}
|
||||
*/
|
||||
export function normalizeModifierKey(value) {
|
||||
switch (value) {
|
||||
case 'alt':
|
||||
case 'ctrl':
|
||||
case 'meta':
|
||||
case 'shift':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event The event to check.
|
||||
* @param {import('input').ModifierMouseButton[]|import('input').Modifier[]} array
|
||||
*/
|
||||
function getActiveButtonsInternal(event, array) {
|
||||
let {buttons} = event;
|
||||
if (typeof buttons === 'number' && buttons > 0) {
|
||||
for (let i = 0; i < 6; ++i) {
|
||||
const buttonFlag = (1 << i);
|
||||
if ((buttons & buttonFlag) !== 0) {
|
||||
array.push(/** @type {import('input').ModifierMouseButton} */ (`mouse${i}`));
|
||||
buttons &= ~buttonFlag;
|
||||
if (buttons === 0) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|number|undefined} value
|
||||
* @returns {?number}
|
||||
*/
|
||||
function convertToNumberOrNull(value) {
|
||||
if (typeof value !== 'number') {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
value = Number.parseFloat(value);
|
||||
}
|
||||
return !Number.isNaN(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes whether or not this browser and document supports CSS zoom, which is primarily a legacy Chromium feature.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function computeCssZoomSupported() {
|
||||
// 'style' can be undefined in certain contexts, such as when document is an SVG document.
|
||||
const {style} = document.createElement('div');
|
||||
return (
|
||||
typeof style === 'object' &&
|
||||
style !== null &&
|
||||
typeof style.zoom === 'string'
|
||||
);
|
||||
}
|
||||
319
vendor/yomitan/js/dom/dom-data-binder.js
vendored
Normal file
319
vendor/yomitan/js/dom/dom-data-binder.js
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* 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 {TaskAccumulator} from '../general/task-accumulator.js';
|
||||
import {convertElementValueToNumber} from './document-util.js';
|
||||
import {SelectorObserver} from './selector-observer.js';
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
*/
|
||||
export class DOMDataBinder {
|
||||
/**
|
||||
* @param {string[]} selectors
|
||||
* @param {import('dom-data-binder').CreateElementMetadataCallback<T>} createElementMetadata
|
||||
* @param {import('dom-data-binder').CompareElementMetadataCallback<T>} compareElementMetadata
|
||||
* @param {import('dom-data-binder').GetValuesCallback<T>} getValues
|
||||
* @param {import('dom-data-binder').SetValuesCallback<T>} setValues
|
||||
* @param {import('dom-data-binder').OnErrorCallback<T>|null} [onError]
|
||||
*/
|
||||
constructor(selectors, createElementMetadata, compareElementMetadata, getValues, setValues, onError = null) {
|
||||
/** @type {string[]} */
|
||||
this._selectors = selectors;
|
||||
/** @type {import('dom-data-binder').CreateElementMetadataCallback<T>} */
|
||||
this._createElementMetadata = createElementMetadata;
|
||||
/** @type {import('dom-data-binder').CompareElementMetadataCallback<T>} */
|
||||
this._compareElementMetadata = compareElementMetadata;
|
||||
/** @type {import('dom-data-binder').GetValuesCallback<T>} */
|
||||
this._getValues = getValues;
|
||||
/** @type {import('dom-data-binder').SetValuesCallback<T>} */
|
||||
this._setValues = setValues;
|
||||
/** @type {?import('dom-data-binder').OnErrorCallback<T>} */
|
||||
this._onError = onError;
|
||||
/** @type {TaskAccumulator<import('dom-data-binder').ElementObserver<T>, import('dom-data-binder').UpdateTaskValue>} */
|
||||
this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this));
|
||||
/** @type {TaskAccumulator<import('dom-data-binder').ElementObserver<T>, import('dom-data-binder').AssignTaskValue>} */
|
||||
this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this));
|
||||
/** @type {SelectorObserver<import('dom-data-binder').ElementObserver<T>>[]} */
|
||||
this._selectorObservers = selectors.map((selector) => new SelectorObserver({
|
||||
selector,
|
||||
ignoreSelector: null,
|
||||
onAdded: this._createObserver.bind(this),
|
||||
onRemoved: this._removeObserver.bind(this),
|
||||
onChildrenUpdated: this._onObserverChildrenUpdated.bind(this),
|
||||
isStale: this._isObserverStale.bind(this),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
*/
|
||||
observe(element) {
|
||||
for (const selectorObserver of this._selectorObservers) {
|
||||
selectorObserver.observe(element, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
disconnect() {
|
||||
for (const selectorObserver of this._selectorObservers) {
|
||||
selectorObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
async refresh() {
|
||||
await this._updateTasks.enqueue(null, {all: true});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').UpdateTask<T>[]} tasks
|
||||
*/
|
||||
async _onBulkUpdate(tasks) {
|
||||
let all = false;
|
||||
/** @type {import('dom-data-binder').ApplyTarget<T>[]} */
|
||||
const targets = [];
|
||||
for (const [observer, task] of tasks) {
|
||||
if (observer === null) {
|
||||
if (task.data.all) {
|
||||
all = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
targets.push([observer, task]);
|
||||
}
|
||||
}
|
||||
if (all) {
|
||||
targets.length = 0;
|
||||
for (const selectorObserver of this._selectorObservers) {
|
||||
for (const observer of selectorObserver.datas()) {
|
||||
targets.push([observer, null]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const args = targets.map(([observer]) => ({
|
||||
element: observer.element,
|
||||
metadata: observer.metadata,
|
||||
}));
|
||||
const responses = await this._getValues(args);
|
||||
this._applyValues(targets, responses, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').AssignTask<T>[]} tasks
|
||||
*/
|
||||
async _onBulkAssign(tasks) {
|
||||
/** @type {import('dom-data-binder').ApplyTarget<T>[]} */
|
||||
const targets = [];
|
||||
const args = [];
|
||||
for (const [observer, task] of tasks) {
|
||||
if (observer === null) { continue; }
|
||||
args.push({
|
||||
element: observer.element,
|
||||
metadata: observer.metadata,
|
||||
value: task.data.value,
|
||||
});
|
||||
targets.push([observer, task]);
|
||||
}
|
||||
const responses = await this._setValues(args);
|
||||
this._applyValues(targets, responses, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').ElementObserver<T>} observer
|
||||
*/
|
||||
_onElementChange(observer) {
|
||||
const value = this._getElementValue(observer.element);
|
||||
observer.value = value;
|
||||
observer.hasValue = true;
|
||||
void this._assignTasks.enqueue(observer, {value});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dom-data-binder').ApplyTarget<T>[]} targets
|
||||
* @param {import('dom-data-binder').TaskResult[]} response
|
||||
* @param {boolean} ignoreStale
|
||||
*/
|
||||
_applyValues(targets, response, ignoreStale) {
|
||||
for (let i = 0, ii = targets.length; i < ii; ++i) {
|
||||
const [observer, task] = targets[i];
|
||||
const {error, result} = response[i];
|
||||
const stale = (task !== null && task.stale);
|
||||
|
||||
if (error) {
|
||||
if (typeof this._onError === 'function') {
|
||||
this._onError(error, stale, observer.element, observer.metadata);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stale && !ignoreStale) { continue; }
|
||||
|
||||
observer.value = result;
|
||||
observer.hasValue = true;
|
||||
this._setElementValue(observer.element, result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {import('dom-data-binder').ElementObserver<T>|undefined}
|
||||
*/
|
||||
_createObserver(element) {
|
||||
const metadata = this._createElementMetadata(element);
|
||||
if (typeof metadata === 'undefined') { return void 0; }
|
||||
const type = this._getNormalizedElementType(element);
|
||||
const eventType = 'change';
|
||||
/** @type {import('dom-data-binder').ElementObserver<T>} */
|
||||
const observer = {
|
||||
element,
|
||||
type,
|
||||
value: null,
|
||||
hasValue: false,
|
||||
eventType,
|
||||
onChange: null,
|
||||
metadata,
|
||||
};
|
||||
observer.onChange = this._onElementChange.bind(this, observer);
|
||||
element.addEventListener(eventType, observer.onChange, false);
|
||||
|
||||
void this._updateTasks.enqueue(observer, {all: false});
|
||||
|
||||
return observer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {import('dom-data-binder').ElementObserver<T>} observer
|
||||
*/
|
||||
_removeObserver(element, observer) {
|
||||
if (observer.onChange === null) { return; }
|
||||
element.removeEventListener(observer.eventType, observer.onChange, false);
|
||||
observer.onChange = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {import('dom-data-binder').ElementObserver<T>} observer
|
||||
*/
|
||||
_onObserverChildrenUpdated(element, observer) {
|
||||
if (observer.hasValue && this._getNormalizedElementType(element) !== 'element') {
|
||||
this._setElementValue(element, observer.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {import('dom-data-binder').ElementObserver<T>} observer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isObserverStale(element, observer) {
|
||||
const {type, metadata} = observer;
|
||||
if (type !== this._getNormalizedElementType(element)) { return false; }
|
||||
const newMetadata = this._createElementMetadata(element);
|
||||
return typeof newMetadata === 'undefined' || !this._compareElementMetadata(metadata, newMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {unknown} value
|
||||
*/
|
||||
_setElementValue(element, value) {
|
||||
switch (this._getNormalizedElementType(element)) {
|
||||
case 'checkbox':
|
||||
/** @type {HTMLInputElement} */ (element).checked = typeof value === 'boolean' && value;
|
||||
break;
|
||||
case 'text':
|
||||
case 'number':
|
||||
case 'textarea':
|
||||
case 'select':
|
||||
/** @type {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} */ (element).value = typeof value === 'string' ? value : `${value}`;
|
||||
break;
|
||||
case 'element':
|
||||
element.textContent = typeof value === 'string' ? value : `${value}`;
|
||||
break;
|
||||
}
|
||||
|
||||
/** @type {number|string|boolean} */
|
||||
let safeValue;
|
||||
switch (typeof value) {
|
||||
case 'number':
|
||||
case 'string':
|
||||
case 'boolean':
|
||||
safeValue = value;
|
||||
break;
|
||||
default:
|
||||
safeValue = `${value}`;
|
||||
break;
|
||||
}
|
||||
/** @type {import('dom-data-binder').SettingChangedEvent} */
|
||||
const event = new CustomEvent('settingChanged', {detail: {value: safeValue}});
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {boolean|string|number|null}
|
||||
*/
|
||||
_getElementValue(element) {
|
||||
switch (this._getNormalizedElementType(element)) {
|
||||
case 'checkbox':
|
||||
return !!(/** @type {HTMLInputElement} */ (element).checked);
|
||||
case 'text':
|
||||
return `${/** @type {HTMLInputElement} */ (element).value}`;
|
||||
case 'number':
|
||||
return convertElementValueToNumber(/** @type {HTMLInputElement} */ (element).value, /** @type {HTMLInputElement} */ (element));
|
||||
case 'textarea':
|
||||
return /** @type {HTMLTextAreaElement} */ (element).value;
|
||||
case 'select':
|
||||
return /** @type {HTMLSelectElement} */ (element).value;
|
||||
case 'element':
|
||||
return element.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {import('dom-data-binder').NormalizedElementType}
|
||||
*/
|
||||
_getNormalizedElementType(element) {
|
||||
switch (element.nodeName.toUpperCase()) {
|
||||
case 'INPUT':
|
||||
{
|
||||
const {type} = /** @type {HTMLInputElement} */ (element);
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'password':
|
||||
return 'text';
|
||||
case 'number':
|
||||
case 'checkbox':
|
||||
return type;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'TEXTAREA':
|
||||
return 'textarea';
|
||||
case 'SELECT':
|
||||
return 'select';
|
||||
}
|
||||
return 'element';
|
||||
}
|
||||
}
|
||||
625
vendor/yomitan/js/dom/dom-text-scanner.js
vendored
Normal file
625
vendor/yomitan/js/dom/dom-text-scanner.js
vendored
Normal file
@@ -0,0 +1,625 @@
|
||||
/*
|
||||
* 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 {readCodePointsBackward, readCodePointsForward} from '../data/string-util.js';
|
||||
|
||||
/**
|
||||
* A class used to scan text in a document.
|
||||
*/
|
||||
export class DOMTextScanner {
|
||||
/**
|
||||
* A regular expression used to match word delimiters.
|
||||
* \p{L} matches any kind of letter from any language
|
||||
* \p{N} matches any kind of numeric character in any script
|
||||
* @type {RegExp}
|
||||
*/
|
||||
static WORD_DELIMITER_REGEX = /[^\w\p{L}\p{N}]/u;
|
||||
|
||||
/**
|
||||
* Creates a new instance of a DOMTextScanner.
|
||||
* @param {Node} node The DOM Node to start at.
|
||||
* @param {number} offset The character offset in to start at when node is a text node.
|
||||
* Use 0 for non-text nodes.
|
||||
* @param {boolean} forcePreserveWhitespace Whether or not whitespace should be forced to be preserved,
|
||||
* regardless of CSS styling.
|
||||
* @param {boolean} generateLayoutContent Whether or not newlines should be added based on CSS styling.
|
||||
* @param {boolean} stopAtWordBoundary Whether to pause scanning when whitespace is encountered when scanning backwards.
|
||||
*/
|
||||
constructor(node, offset, forcePreserveWhitespace = false, generateLayoutContent = true, stopAtWordBoundary = false) {
|
||||
const ruby = DOMTextScanner.getParentRubyElement(node);
|
||||
const resetOffset = (ruby !== null);
|
||||
if (resetOffset) { node = ruby; }
|
||||
|
||||
/** @type {Node} */
|
||||
this._initialNode = node;
|
||||
/** @type {Node} */
|
||||
this._node = node;
|
||||
/** @type {number} */
|
||||
this._offset = offset;
|
||||
/** @type {string} */
|
||||
this._content = '';
|
||||
/** @type {number} */
|
||||
this._remainder = 0;
|
||||
/** @type {boolean} */
|
||||
this._resetOffset = resetOffset;
|
||||
/** @type {number} */
|
||||
this._newlines = 0;
|
||||
/** @type {boolean} */
|
||||
this._lineHasWhitespace = false;
|
||||
/** @type {boolean} */
|
||||
this._lineHasContent = false;
|
||||
/**
|
||||
* @type {boolean} Whether or not whitespace should be forced to be preserved,
|
||||
* regardless of CSS styling.
|
||||
*/
|
||||
this._forcePreserveWhitespace = forcePreserveWhitespace;
|
||||
/** @type {boolean} */
|
||||
this._generateLayoutContent = generateLayoutContent;
|
||||
/**
|
||||
* @type {boolean} Whether or not to stop scanning when word boundaries are encountered.
|
||||
*/
|
||||
this._stopAtWordBoundary = stopAtWordBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current node being scanned.
|
||||
* @type {Node}
|
||||
*/
|
||||
get node() {
|
||||
return this._node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current offset corresponding to the node being scanned.
|
||||
* This value is only applicable for text nodes.
|
||||
* @type {number}
|
||||
*/
|
||||
get offset() {
|
||||
return this._offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the remaining number of characters that weren't scanned in the last seek() call.
|
||||
* This value is usually 0 unless the end of the document was reached.
|
||||
* @type {number}
|
||||
*/
|
||||
get remainder() {
|
||||
return this._remainder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the accumulated content string resulting from calls to seek().
|
||||
* @type {string}
|
||||
*/
|
||||
get content() {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks a given length in the document and accumulates the text content.
|
||||
* @param {number} length A positive or negative integer corresponding to how many characters
|
||||
* should be added to content. Content is only added to the accumulation string,
|
||||
* never removed, so mixing seek calls with differently signed length values
|
||||
* may give unexpected results.
|
||||
* @returns {DOMTextScanner} this
|
||||
*/
|
||||
seek(length) {
|
||||
const forward = (length >= 0);
|
||||
this._remainder = (forward ? length : -length);
|
||||
if (length === 0) { return this; }
|
||||
|
||||
const TEXT_NODE = Node.TEXT_NODE;
|
||||
const ELEMENT_NODE = Node.ELEMENT_NODE;
|
||||
|
||||
const generateLayoutContent = this._generateLayoutContent;
|
||||
let node = /** @type {?Node} */ (this._node);
|
||||
let lastNode = /** @type {Node} */ (node);
|
||||
let resetOffset = this._resetOffset;
|
||||
let newlines = 0;
|
||||
seekLoop:
|
||||
while (node !== null) {
|
||||
let enterable = false;
|
||||
const nodeType = node.nodeType;
|
||||
|
||||
if (nodeType === TEXT_NODE) {
|
||||
lastNode = node;
|
||||
const shouldContinueScanning = forward ?
|
||||
this._seekTextNodeForward(/** @type {Text} */ (node), resetOffset) :
|
||||
this._seekTextNodeBackward(/** @type {Text} */ (node), resetOffset);
|
||||
|
||||
if (!shouldContinueScanning) {
|
||||
// Length reached or reached a word boundary
|
||||
break;
|
||||
}
|
||||
} else if (nodeType === ELEMENT_NODE) {
|
||||
if (this._stopAtWordBoundary && !forward) {
|
||||
// Element nodes are considered word boundaries when scanning backwards
|
||||
break;
|
||||
}
|
||||
lastNode = node;
|
||||
const initialNodeAtBeginningOfNodeGoingBackwards = node === this._initialNode && this._offset === 0 && !forward;
|
||||
const initialNodeAtEndOfNodeGoingForwards = node === this._initialNode && this._offset === node.childNodes.length && forward;
|
||||
this._offset = 0;
|
||||
const isInitialNode = node === this._initialNode;
|
||||
({enterable, newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (node)));
|
||||
if (!isInitialNode && newlines > this._newlines && generateLayoutContent) {
|
||||
this._newlines = newlines;
|
||||
}
|
||||
if (initialNodeAtBeginningOfNodeGoingBackwards || initialNodeAtEndOfNodeGoingForwards) {
|
||||
enterable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Node[]} */
|
||||
const exitedNodes = [];
|
||||
node = DOMTextScanner.getNextNodeToProcess(node, forward, enterable, exitedNodes);
|
||||
|
||||
for (const exitedNode of exitedNodes) {
|
||||
if (exitedNode.nodeType !== ELEMENT_NODE) { continue; }
|
||||
({newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (exitedNode)));
|
||||
if (newlines > this._newlines && generateLayoutContent) {
|
||||
this._newlines = newlines;
|
||||
}
|
||||
if (newlines > 0 && this._stopAtWordBoundary && !forward) {
|
||||
// Element nodes are considered word boundaries when scanning backwards
|
||||
break seekLoop;
|
||||
}
|
||||
}
|
||||
|
||||
resetOffset = true;
|
||||
}
|
||||
|
||||
this._node = lastNode;
|
||||
this._resetOffset = resetOffset;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* Seeks forward in a text node.
|
||||
* @param {Text} textNode The text node to use.
|
||||
* @param {boolean} resetOffset Whether or not the text offset should be reset.
|
||||
* @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached.
|
||||
*/
|
||||
_seekTextNodeForward(textNode, resetOffset) {
|
||||
const nodeValue = /** @type {string} */ (textNode.nodeValue);
|
||||
const nodeValueLength = nodeValue.length;
|
||||
const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode);
|
||||
if (resetOffset) { this._offset = 0; }
|
||||
|
||||
while (this._offset < nodeValueLength) {
|
||||
const char = readCodePointsForward(nodeValue, this._offset, 1);
|
||||
this._offset += char.length;
|
||||
const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
|
||||
if (this._checkCharacterForward(char, charAttributes)) { break; }
|
||||
}
|
||||
|
||||
return this._remainder > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks backward in a text node.
|
||||
* This function is nearly the same as _seekTextNodeForward, with the following differences:
|
||||
* - Iteration condition is reversed to check if offset is greater than 0.
|
||||
* - offset is reset to nodeValueLength instead of 0.
|
||||
* - offset is decremented instead of incremented.
|
||||
* - offset is decremented before getting the character.
|
||||
* - offset is reverted by incrementing instead of decrementing.
|
||||
* - content string is prepended instead of appended.
|
||||
* @param {Text} textNode The text node to use.
|
||||
* @param {boolean} resetOffset Whether or not the text offset should be reset.
|
||||
* @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached.
|
||||
*/
|
||||
_seekTextNodeBackward(textNode, resetOffset) {
|
||||
const nodeValue = /** @type {string} */ (textNode.nodeValue);
|
||||
const nodeValueLength = nodeValue.length;
|
||||
const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode);
|
||||
if (resetOffset) { this._offset = nodeValueLength; }
|
||||
while (this._offset > 0) {
|
||||
const char = readCodePointsBackward(nodeValue, this._offset - 1, 1);
|
||||
if (this._stopAtWordBoundary && DOMTextScanner.isWordDelimiter(char)) {
|
||||
if (DOMTextScanner.isSingleQuote(char) && this._offset > 1) {
|
||||
// Check to see if char before single quote is a word character (e.g. "don't")
|
||||
const prevChar = readCodePointsBackward(nodeValue, this._offset - 2, 1);
|
||||
if (DOMTextScanner.isWordDelimiter(prevChar)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this._offset -= char.length;
|
||||
const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
|
||||
if (this._checkCharacterBackward(char, charAttributes)) { break; }
|
||||
}
|
||||
|
||||
return this._remainder > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets information about how whitespace characters are treated.
|
||||
* @param {Text} textNode
|
||||
* @returns {import('dom-text-scanner').WhitespaceSettings}
|
||||
*/
|
||||
_getWhitespaceSettings(textNode) {
|
||||
if (this._forcePreserveWhitespace) {
|
||||
return {preserveNewlines: true, preserveWhitespace: true};
|
||||
}
|
||||
const element = DOMTextScanner.getParentElement(textNode);
|
||||
if (element !== null) {
|
||||
const style = window.getComputedStyle(element);
|
||||
switch (style.whiteSpace) {
|
||||
case 'pre':
|
||||
case 'pre-wrap':
|
||||
case 'break-spaces':
|
||||
return {preserveNewlines: true, preserveWhitespace: true};
|
||||
case 'pre-line':
|
||||
return {preserveNewlines: true, preserveWhitespace: false};
|
||||
}
|
||||
}
|
||||
return {preserveNewlines: false, preserveWhitespace: false};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} char
|
||||
* @param {import('dom-text-scanner').CharacterAttributes} charAttributes
|
||||
* @returns {boolean} Whether or not to stop scanning.
|
||||
*/
|
||||
_checkCharacterForward(char, charAttributes) {
|
||||
switch (charAttributes) {
|
||||
// case 0: break; // NOP
|
||||
case 1:
|
||||
this._lineHasWhitespace = true;
|
||||
break;
|
||||
case 2:
|
||||
case 3:
|
||||
if (this._newlines > 0) {
|
||||
const useNewlineCount = Math.min(this._remainder, this._newlines);
|
||||
this._content += '\n'.repeat(useNewlineCount);
|
||||
this._remainder -= useNewlineCount;
|
||||
this._newlines -= useNewlineCount;
|
||||
this._lineHasContent = false;
|
||||
this._lineHasWhitespace = false;
|
||||
if (this._remainder <= 0) {
|
||||
this._offset -= char.length; // Revert character offset
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this._lineHasContent = (charAttributes === 2); // 3 = character is a newline
|
||||
|
||||
if (this._lineHasWhitespace) {
|
||||
if (this._lineHasContent) {
|
||||
this._content += ' ';
|
||||
this._lineHasWhitespace = false;
|
||||
if (--this._remainder <= 0) {
|
||||
this._offset -= char.length; // Revert character offset
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
this._lineHasWhitespace = false;
|
||||
}
|
||||
}
|
||||
|
||||
this._content += char;
|
||||
|
||||
if (--this._remainder <= 0) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} char
|
||||
* @param {import('dom-text-scanner').CharacterAttributes} charAttributes
|
||||
* @returns {boolean} Whether or not to stop scanning.
|
||||
*/
|
||||
_checkCharacterBackward(char, charAttributes) {
|
||||
switch (charAttributes) {
|
||||
// case 0: break; // NOP
|
||||
case 1:
|
||||
this._lineHasWhitespace = true;
|
||||
break;
|
||||
case 2:
|
||||
case 3:
|
||||
if (this._newlines > 0) {
|
||||
const useNewlineCount = Math.min(this._remainder, this._newlines);
|
||||
this._content = '\n'.repeat(useNewlineCount) + this._content;
|
||||
this._remainder -= useNewlineCount;
|
||||
this._newlines -= useNewlineCount;
|
||||
this._lineHasContent = false;
|
||||
this._lineHasWhitespace = false;
|
||||
if (this._remainder <= 0) {
|
||||
this._offset += char.length; // Revert character offset
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this._lineHasContent = (charAttributes === 2); // 3 = character is a newline
|
||||
|
||||
if (this._lineHasWhitespace) {
|
||||
if (this._lineHasContent) {
|
||||
this._content = ' ' + this._content;
|
||||
this._lineHasWhitespace = false;
|
||||
if (--this._remainder <= 0) {
|
||||
this._offset += char.length; // Revert character offset
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
this._lineHasWhitespace = false;
|
||||
}
|
||||
}
|
||||
|
||||
this._content = char + this._content;
|
||||
|
||||
if (--this._remainder <= 0) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Static helpers
|
||||
|
||||
/**
|
||||
* Gets the next node to process in the document for a specified scanning direction.
|
||||
* @param {Node} node The current DOM Node.
|
||||
* @param {boolean} forward Whether to scan forward in the document or backward.
|
||||
* @param {boolean} visitChildren Whether the children of the current node should be visited.
|
||||
* @param {Node[]} exitedNodes An array which stores nodes which were exited.
|
||||
* @returns {?Node} The next node in the document, or `null` if there is no next node.
|
||||
*/
|
||||
static getNextNodeToProcess(node, forward, visitChildren, exitedNodes) {
|
||||
/** @type {?Node} */
|
||||
let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null;
|
||||
if (next === null) {
|
||||
while (true) {
|
||||
exitedNodes.push(node);
|
||||
|
||||
next = (forward ? node.nextSibling : node.previousSibling);
|
||||
if (next !== null) { break; }
|
||||
|
||||
next = node.parentNode;
|
||||
if (next === null) { break; }
|
||||
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent element of a given Node.
|
||||
* @param {?Node} node The node to check.
|
||||
* @returns {?Element} The parent element if one exists, otherwise `null`.
|
||||
*/
|
||||
static getParentElement(node) {
|
||||
while (node !== null) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
return /** @type {Element} */ (node);
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent <ruby> element of a given node, if one exists. For efficiency purposes,
|
||||
* this only checks the immediate parent elements and does not check all ancestors, so
|
||||
* there are cases where the node may be in a ruby element but it is not returned.
|
||||
* @param {Node} node The node to check.
|
||||
* @returns {?HTMLElement} A <ruby> node if the input node is contained in one, otherwise `null`.
|
||||
*/
|
||||
static getParentRubyElement(node) {
|
||||
/** @type {?Node} */
|
||||
let node2 = DOMTextScanner.getParentElement(node);
|
||||
if (node2 !== null && node2.nodeName.toUpperCase() === 'RT') {
|
||||
node2 = node2.parentNode;
|
||||
if (node2 !== null && node2.nodeName.toUpperCase() === 'RUBY') {
|
||||
return /** @type {HTMLElement} */ (node2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {import('dom-text-scanner').ElementSeekInfo}
|
||||
*/
|
||||
static getElementSeekInfo(element) {
|
||||
let enterable = true;
|
||||
switch (element.nodeName.toUpperCase()) {
|
||||
case 'HEAD':
|
||||
case 'RT':
|
||||
case 'SCRIPT':
|
||||
case 'STYLE':
|
||||
return {enterable: false, newlines: 0};
|
||||
case 'RB':
|
||||
return {enterable: true, newlines: 0};
|
||||
case 'BR':
|
||||
return {enterable: false, newlines: 1};
|
||||
case 'TEXTAREA':
|
||||
case 'INPUT':
|
||||
case 'BUTTON':
|
||||
enterable = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
const display = style.display;
|
||||
|
||||
const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style));
|
||||
let newlines = 0;
|
||||
|
||||
if (!visible) {
|
||||
enterable = false;
|
||||
} else {
|
||||
switch (style.position) {
|
||||
case 'absolute':
|
||||
case 'fixed':
|
||||
case 'sticky':
|
||||
newlines = 2;
|
||||
break;
|
||||
}
|
||||
if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) {
|
||||
newlines = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {enterable, newlines};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets attributes for the specified character.
|
||||
* @param {string} character A string containing a single character.
|
||||
* @param {boolean} preserveNewlines Whether or not newlines should be preserved.
|
||||
* @param {boolean} preserveWhitespace Whether or not whitespace should be preserved.
|
||||
* @returns {import('dom-text-scanner').CharacterAttributes} An enum representing the attributes of the character.
|
||||
*/
|
||||
static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) {
|
||||
switch (character.charCodeAt(0)) {
|
||||
case 0x09: // Tab ('\t')
|
||||
case 0x0c: // Form feed ('\f')
|
||||
case 0x0d: // Carriage return ('\r')
|
||||
case 0x20: // Space (' ')
|
||||
return preserveWhitespace ? 2 : 1;
|
||||
case 0x0a: // Line feed ('\n')
|
||||
return preserveNewlines ? 3 : 1;
|
||||
case 0x200b: // Zero-width space
|
||||
case 0x200c: // Zero-width non-joiner
|
||||
case 0x00ad: // Soft hyphen
|
||||
return 0;
|
||||
default: // Other
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} character
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isWordDelimiter(character) {
|
||||
return DOMTextScanner.WORD_DELIMITER_REGEX.test(character);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} character
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isSingleQuote(character) {
|
||||
switch (character.charCodeAt(0)) {
|
||||
case 0x27: // Single quote ('')
|
||||
case 0x2019: // Right single quote (’)
|
||||
case 0x2032: // Prime (′)
|
||||
case 0x2035: // Reversed prime (‵)
|
||||
case 0x02bc: // Modifier letter apostrophe (ʼ)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given style is visible or not.
|
||||
* This function does not check `style.display === 'none'`.
|
||||
* @param {CSSStyleDeclaration} style An object implementing the CSSStyleDeclaration interface.
|
||||
* @returns {boolean} `true` if the style should result in an element being visible, otherwise `false`.
|
||||
*/
|
||||
static isStyleVisible(style) {
|
||||
return !(
|
||||
style.visibility === 'hidden' ||
|
||||
Number.parseFloat(style.opacity) <= 0 ||
|
||||
Number.parseFloat(style.fontSize) <= 0 ||
|
||||
(
|
||||
!DOMTextScanner.isStyleSelectable(style) &&
|
||||
(
|
||||
DOMTextScanner.isCSSColorTransparent(style.color) ||
|
||||
DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given style is selectable or not.
|
||||
* @param {CSSStyleDeclaration} style An object implementing the CSSStyleDeclaration interface.
|
||||
* @returns {boolean} `true` if the style is selectable, otherwise `false`.
|
||||
*/
|
||||
static isStyleSelectable(style) {
|
||||
return !(
|
||||
style.userSelect === 'none' ||
|
||||
style.webkitUserSelect === 'none' ||
|
||||
// @ts-expect-error - vendor prefix
|
||||
style.MozUserSelect === 'none' ||
|
||||
// @ts-expect-error - vendor prefix
|
||||
style.msUserSelect === 'none'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a CSS color is transparent or not.
|
||||
* @param {string} cssColor A CSS color string, expected to be encoded in rgb(a) form.
|
||||
* @returns {boolean} `true` if the color is transparent, otherwise `false`.
|
||||
*/
|
||||
static isCSSColorTransparent(cssColor) {
|
||||
return (
|
||||
typeof cssColor === 'string' &&
|
||||
cssColor.startsWith('rgba(') &&
|
||||
/,\s*0.?0*\)$/.test(cssColor)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a CSS display value will cause a layout change for text.
|
||||
* @param {string} cssDisplay A CSS string corresponding to the value of the display property.
|
||||
* @returns {boolean} `true` if the layout is changed by this value, otherwise `false`.
|
||||
*/
|
||||
static doesCSSDisplayChangeLayout(cssDisplay) {
|
||||
let pos = cssDisplay.indexOf(' ');
|
||||
if (pos >= 0) {
|
||||
// Truncate to <display-outside> part
|
||||
cssDisplay = cssDisplay.substring(0, pos);
|
||||
}
|
||||
|
||||
pos = cssDisplay.indexOf('-');
|
||||
if (pos >= 0) {
|
||||
// Truncate to first part of kebab-case value
|
||||
cssDisplay = cssDisplay.substring(0, pos);
|
||||
}
|
||||
|
||||
switch (cssDisplay) {
|
||||
case 'block':
|
||||
case 'flex':
|
||||
case 'grid':
|
||||
case 'list': // Also includes: list-item
|
||||
case 'table': // Also includes: table, table-*
|
||||
return true;
|
||||
case 'ruby': // Also includes: ruby-*
|
||||
return (pos >= 0);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
123
vendor/yomitan/js/dom/html-template-collection.js
vendored
Normal file
123
vendor/yomitan/js/dom/html-template-collection.js
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 {fetchText} from '../core/fetch-utilities.js';
|
||||
|
||||
export class HtmlTemplateCollection {
|
||||
constructor() {
|
||||
/** @type {Map<string, HTMLTemplateElement>} */
|
||||
this._templates = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} urls
|
||||
*/
|
||||
async loadFromFiles(urls) {
|
||||
const htmlRawArray = await Promise.all(urls.map((url) => fetchText(url)));
|
||||
const domParser = new DOMParser();
|
||||
for (const htmlRaw of htmlRawArray) {
|
||||
const templatesDocument = domParser.parseFromString(htmlRaw, 'text/html');
|
||||
this.load(templatesDocument);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Document} source
|
||||
*/
|
||||
load(source) {
|
||||
const pattern = /^([\w\W]+)-template$/;
|
||||
for (const template of source.querySelectorAll('template')) {
|
||||
const match = pattern.exec(template.id);
|
||||
if (match === null) { continue; }
|
||||
this._prepareTemplate(template);
|
||||
this._templates.set(match[1], template);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {Element} T
|
||||
* @param {string} name
|
||||
* @returns {T}
|
||||
* @throws {Error}
|
||||
*/
|
||||
instantiate(name) {
|
||||
const {firstElementChild} = this.getTemplateContent(name);
|
||||
if (firstElementChild === null) { throw new Error(`Failed to find template content element: ${name}`); }
|
||||
return /** @type {T} */ (document.importNode(firstElementChild, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
instantiateFragment(name) {
|
||||
return document.importNode(this.getTemplateContent(name), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {DocumentFragment}
|
||||
* @throws {Error}
|
||||
*/
|
||||
getTemplateContent(name) {
|
||||
const template = this._templates.get(name);
|
||||
if (typeof template === 'undefined') { throw new Error(`Failed to find template: ${name}`); }
|
||||
return template.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {IterableIterator<HTMLTemplateElement>}
|
||||
*/
|
||||
getAllTemplates() {
|
||||
return this._templates.values();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {HTMLTemplateElement} template
|
||||
*/
|
||||
_prepareTemplate(template) {
|
||||
if (template.dataset.removeWhitespaceText === 'true') {
|
||||
this._removeWhitespaceText(template);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLTemplateElement} template
|
||||
*/
|
||||
_removeWhitespaceText(template) {
|
||||
const {content} = template;
|
||||
const {TEXT_NODE} = Node;
|
||||
const iterator = document.createNodeIterator(content, NodeFilter.SHOW_TEXT);
|
||||
const removeNodes = [];
|
||||
while (true) {
|
||||
const node = iterator.nextNode();
|
||||
if (node === null) { break; }
|
||||
if (node.nodeType === TEXT_NODE && /** @type {string} */ (node.nodeValue).trim().length === 0) {
|
||||
removeNodes.push(node);
|
||||
}
|
||||
}
|
||||
for (const node of removeNodes) {
|
||||
const {parentNode} = node;
|
||||
if (parentNode !== null) {
|
||||
parentNode.removeChild(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
vendor/yomitan/js/dom/native-simple-dom-parser.js
vendored
Normal file
105
vendor/yomitan/js/dom/native-simple-dom-parser.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export class NativeSimpleDOMParser {
|
||||
/**
|
||||
* @param {string} content
|
||||
*/
|
||||
constructor(content) {
|
||||
/** @type {Document} */
|
||||
this._document = new DOMParser().parseFromString(content, 'text/html');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {import('simple-dom-parser').Element} [root]
|
||||
* @returns {?import('simple-dom-parser').Element}
|
||||
*/
|
||||
getElementById(id, root) {
|
||||
return this._convertElementOrDocument(root).querySelector(`[id='${id}']`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
* @param {import('simple-dom-parser').Element} [root]
|
||||
* @returns {?import('simple-dom-parser').Element}
|
||||
*/
|
||||
getElementByTagName(tagName, root) {
|
||||
return this._convertElementOrDocument(root).querySelector(tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
* @param {import('simple-dom-parser').Element} [root]
|
||||
* @returns {import('simple-dom-parser').Element[]}
|
||||
*/
|
||||
getElementsByTagName(tagName, root) {
|
||||
return [...this._convertElementOrDocument(root).querySelectorAll(tagName)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} className
|
||||
* @param {import('simple-dom-parser').Element} [root]
|
||||
* @returns {import('simple-dom-parser').Element[]}
|
||||
*/
|
||||
getElementsByClassName(className, root) {
|
||||
return [...this._convertElementOrDocument(root).querySelectorAll(`.${className}`)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('simple-dom-parser').Element} element
|
||||
* @param {string} attribute
|
||||
* @returns {?string}
|
||||
*/
|
||||
getAttribute(element, attribute) {
|
||||
const element2 = this._convertElement(element);
|
||||
return element2.hasAttribute(attribute) ? element2.getAttribute(attribute) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('simple-dom-parser').Element} element
|
||||
* @returns {string}
|
||||
*/
|
||||
getTextContent(element) {
|
||||
const {textContent} = this._convertElement(element);
|
||||
return typeof textContent === 'string' ? textContent : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isSupported() {
|
||||
return typeof DOMParser !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('simple-dom-parser').Element} element
|
||||
* @returns {Element}
|
||||
*/
|
||||
_convertElement(element) {
|
||||
return /** @type {Element} */ (element);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('simple-dom-parser').Element|undefined} element
|
||||
* @returns {Element|Document}
|
||||
*/
|
||||
_convertElementOrDocument(element) {
|
||||
return typeof element !== 'undefined' ? /** @type {Element} */ (element) : this._document;
|
||||
}
|
||||
}
|
||||
138
vendor/yomitan/js/dom/panel-element.js
vendored
Normal file
138
vendor/yomitan/js/dom/panel-element.js
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('panel-element').Events>
|
||||
*/
|
||||
export class PanelElement extends EventDispatcher {
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
* @param {number} closingAnimationDuration
|
||||
*/
|
||||
constructor(node, closingAnimationDuration) {
|
||||
super();
|
||||
/** @type {HTMLElement} */
|
||||
this._node = node;
|
||||
/** @type {number} */
|
||||
this._closingAnimationDuration = closingAnimationDuration;
|
||||
/** @type {string} */
|
||||
this._hiddenAnimatingClass = 'hidden-animating';
|
||||
/** @type {?MutationObserver} */
|
||||
this._mutationObserver = null;
|
||||
/** @type {boolean} */
|
||||
this._visible = false;
|
||||
/** @type {?import('core').Timeout} */
|
||||
this._closeTimer = null;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get node() {
|
||||
return this._node;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isVisible() {
|
||||
return !this._node.hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
* @param {boolean} [animate]
|
||||
*/
|
||||
setVisible(value, animate = true) {
|
||||
value = !!value;
|
||||
if (this.isVisible() === value) { return; }
|
||||
|
||||
if (this._closeTimer !== null) {
|
||||
clearTimeout(this._closeTimer);
|
||||
this._completeClose(true);
|
||||
}
|
||||
|
||||
const node = this._node;
|
||||
const {classList} = node;
|
||||
if (value) {
|
||||
if (animate) { classList.add(this._hiddenAnimatingClass); }
|
||||
getComputedStyle(node).getPropertyValue('display'); // Force update of CSS display property, allowing animation
|
||||
classList.remove(this._hiddenAnimatingClass);
|
||||
node.hidden = false;
|
||||
node.focus();
|
||||
} else {
|
||||
if (animate) { classList.add(this._hiddenAnimatingClass); }
|
||||
node.hidden = true;
|
||||
if (animate) {
|
||||
this._closeTimer = setTimeout(() => this._completeClose(false), this._closingAnimationDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('core').EventNames<import('panel-element').Events>} TName
|
||||
* @param {TName} eventName
|
||||
* @param {(details: import('core').EventArgument<import('panel-element').Events, TName>) => void} callback
|
||||
*/
|
||||
on(eventName, callback) {
|
||||
if (eventName === 'visibilityChanged' && this._mutationObserver === null) {
|
||||
this._visible = this.isVisible();
|
||||
this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
|
||||
this._mutationObserver.observe(this._node, {
|
||||
attributes: true,
|
||||
attributeFilter: ['hidden'],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
}
|
||||
super.on(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {import('core').EventNames<import('panel-element').Events>} TName
|
||||
* @param {TName} eventName
|
||||
* @param {(details: import('core').EventArgument<import('panel-element').Events, TName>) => void} callback
|
||||
* @returns {boolean}
|
||||
*/
|
||||
off(eventName, callback) {
|
||||
const result = super.off(eventName, callback);
|
||||
if (eventName === 'visibilityChanged' && !this.hasListeners(eventName) && this._mutationObserver !== null) {
|
||||
this._mutationObserver.disconnect();
|
||||
this._mutationObserver = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/** */
|
||||
_onMutation() {
|
||||
const visible = this.isVisible();
|
||||
if (this._visible === visible) { return; }
|
||||
this._visible = visible;
|
||||
this.trigger('visibilityChanged', {visible});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} reopening
|
||||
*/
|
||||
_completeClose(reopening) {
|
||||
this._closeTimer = null;
|
||||
this._node.classList.remove(this._hiddenAnimatingClass);
|
||||
this.trigger('closeCompleted', {reopening});
|
||||
}
|
||||
}
|
||||
284
vendor/yomitan/js/dom/popup-menu.js
vendored
Normal file
284
vendor/yomitan/js/dom/popup-menu.js
vendored
Normal file
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* 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 {querySelectorNotNull} from './query-selector.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('popup-menu').Events>
|
||||
*/
|
||||
export class PopupMenu extends EventDispatcher {
|
||||
/**
|
||||
* @param {HTMLElement} sourceElement
|
||||
* @param {HTMLElement} containerNode
|
||||
*/
|
||||
constructor(sourceElement, containerNode) {
|
||||
super();
|
||||
/** @type {HTMLElement} */
|
||||
this._sourceElement = sourceElement;
|
||||
/** @type {HTMLElement} */
|
||||
this._containerNode = containerNode;
|
||||
/** @type {HTMLElement} */
|
||||
this._node = querySelectorNotNull(containerNode, '.popup-menu');
|
||||
/** @type {HTMLElement} */
|
||||
this._bodyNode = querySelectorNotNull(containerNode, '.popup-menu-body');
|
||||
/** @type {boolean} */
|
||||
this._isClosed = false;
|
||||
/** @type {EventListenerCollection} */
|
||||
this._eventListeners = new EventListenerCollection();
|
||||
/** @type {EventListenerCollection} */
|
||||
this._itemEventListeners = new EventListenerCollection();
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get sourceElement() {
|
||||
return this._sourceElement;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get containerNode() {
|
||||
return this._containerNode;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get node() {
|
||||
return this._node;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
get bodyNode() {
|
||||
return this._bodyNode;
|
||||
}
|
||||
|
||||
/** @type {boolean} */
|
||||
get isClosed() {
|
||||
return this._isClosed;
|
||||
}
|
||||
|
||||
/** */
|
||||
prepare() {
|
||||
this._setPosition();
|
||||
this._containerNode.focus();
|
||||
|
||||
this._eventListeners.addEventListener(window, 'resize', this._onWindowResize.bind(this), false);
|
||||
this._eventListeners.addEventListener(this._containerNode, 'click', this._onMenuContainerClick.bind(this), false);
|
||||
|
||||
this.updateMenuItems();
|
||||
|
||||
PopupMenu.openMenus.add(this);
|
||||
|
||||
/** @type {import('popup-menu').MenuOpenEventDetails} */
|
||||
const detail = {menu: this};
|
||||
|
||||
this._sourceElement.dispatchEvent(new CustomEvent('menuOpen', {
|
||||
bubbles: false,
|
||||
cancelable: false,
|
||||
detail,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} [cancelable]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
close(cancelable = true) {
|
||||
return this._close(null, 'close', cancelable, null);
|
||||
}
|
||||
|
||||
/** */
|
||||
updateMenuItems() {
|
||||
this._itemEventListeners.removeAllEventListeners();
|
||||
const items = this._bodyNode.querySelectorAll('.popup-menu-item');
|
||||
const onMenuItemClick = this._onMenuItemClick.bind(this);
|
||||
for (const item of items) {
|
||||
this._itemEventListeners.addEventListener(item, 'click', onMenuItemClick, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
updatePosition() {
|
||||
this._setPosition();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onMenuContainerClick(e) {
|
||||
if (e.currentTarget !== e.target) { return; }
|
||||
if (this._close(null, 'outside', true, e)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_onMenuItemClick(e) {
|
||||
const item = /** @type {HTMLButtonElement} */ (e.currentTarget);
|
||||
if (item.disabled) { return; }
|
||||
if (this._close(item, 'item', true, e)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/** */
|
||||
_onWindowResize() {
|
||||
this._close(null, 'resize', true, null);
|
||||
}
|
||||
|
||||
/** */
|
||||
_setPosition() {
|
||||
// Get flags
|
||||
let horizontal = 1;
|
||||
let vertical = 1;
|
||||
let horizontalCover = 1;
|
||||
let verticalCover = 1;
|
||||
const positionInfo = this._sourceElement.dataset.menuPosition;
|
||||
if (typeof positionInfo === 'string') {
|
||||
const positionInfoSet = new Set(positionInfo.split(' '));
|
||||
|
||||
if (positionInfoSet.has('left')) {
|
||||
horizontal = -1;
|
||||
} else if (positionInfoSet.has('right')) {
|
||||
horizontal = 1;
|
||||
} else if (positionInfoSet.has('h-center')) {
|
||||
horizontal = 0;
|
||||
}
|
||||
|
||||
if (positionInfoSet.has('above')) {
|
||||
vertical = -1;
|
||||
} else if (positionInfoSet.has('below')) {
|
||||
vertical = 1;
|
||||
} else if (positionInfoSet.has('v-center')) {
|
||||
vertical = 0;
|
||||
}
|
||||
|
||||
if (positionInfoSet.has('cover')) {
|
||||
horizontalCover = 1;
|
||||
verticalCover = 1;
|
||||
} else if (positionInfoSet.has('no-cover')) {
|
||||
horizontalCover = -1;
|
||||
verticalCover = -1;
|
||||
}
|
||||
|
||||
if (positionInfoSet.has('h-cover')) {
|
||||
horizontalCover = 1;
|
||||
} else if (positionInfoSet.has('no-h-cover')) {
|
||||
horizontalCover = -1;
|
||||
}
|
||||
|
||||
if (positionInfoSet.has('v-cover')) {
|
||||
verticalCover = 1;
|
||||
} else if (positionInfoSet.has('no-v-cover')) {
|
||||
verticalCover = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Position
|
||||
const menu = this._node;
|
||||
const containerNodeRect = this._containerNode.getBoundingClientRect();
|
||||
const sourceElementRect = this._sourceElement.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
let top = menuRect.top;
|
||||
let bottom = menuRect.bottom;
|
||||
if (verticalCover === 1) {
|
||||
const bodyRect = this._bodyNode.getBoundingClientRect();
|
||||
top = bodyRect.top;
|
||||
bottom = bodyRect.bottom;
|
||||
}
|
||||
|
||||
let x = (
|
||||
sourceElementRect.left +
|
||||
sourceElementRect.width * ((-horizontal * horizontalCover + 1) * 0.5) +
|
||||
menuRect.width * ((-horizontal + 1) * -0.5)
|
||||
);
|
||||
let y = (
|
||||
sourceElementRect.top +
|
||||
(menuRect.top - top) +
|
||||
sourceElementRect.height * ((-vertical * verticalCover + 1) * 0.5) +
|
||||
(bottom - top) * ((-vertical + 1) * -0.5)
|
||||
);
|
||||
|
||||
x = Math.max(0, Math.min(containerNodeRect.width - menuRect.width, x));
|
||||
y = Math.max(0, Math.min(containerNodeRect.height - menuRect.height, y));
|
||||
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?HTMLElement} item
|
||||
* @param {import('popup-menu').CloseReason} cause
|
||||
* @param {boolean} cancelable
|
||||
* @param {?MouseEvent} originalEvent
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_close(item, cause, cancelable, originalEvent) {
|
||||
if (this._isClosed) { return true; }
|
||||
/** @type {?string} */
|
||||
let action = null;
|
||||
if (item !== null) {
|
||||
const {menuAction} = item.dataset;
|
||||
if (typeof menuAction === 'string') { action = menuAction; }
|
||||
}
|
||||
|
||||
const {altKey, ctrlKey, metaKey, shiftKey} = (
|
||||
originalEvent !== null ?
|
||||
originalEvent :
|
||||
{altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
|
||||
);
|
||||
|
||||
/** @type {import('popup-menu').EventArgument<'close'>} */
|
||||
const detail = {
|
||||
menu: this,
|
||||
item,
|
||||
action,
|
||||
cause,
|
||||
altKey,
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
shiftKey,
|
||||
};
|
||||
const result = this._sourceElement.dispatchEvent(new CustomEvent('menuClose', {bubbles: false, cancelable, detail}));
|
||||
if (cancelable && !result) { return false; }
|
||||
|
||||
PopupMenu.openMenus.delete(this);
|
||||
|
||||
this._isClosed = true;
|
||||
this._eventListeners.removeAllEventListeners();
|
||||
this._itemEventListeners.removeAllEventListeners();
|
||||
if (this._containerNode.parentNode !== null) {
|
||||
this._containerNode.parentNode.removeChild(this._containerNode);
|
||||
}
|
||||
|
||||
this.trigger('close', detail);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(PopupMenu, 'openMenus', {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
value: new Set(),
|
||||
});
|
||||
43
vendor/yomitan/js/dom/query-selector.js
vendored
Normal file
43
vendor/yomitan/js/dom/query-selector.js
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* @param {Element|Document|DocumentFragment} element
|
||||
* @param {string} selector
|
||||
* @returns {ExtensionError}
|
||||
*/
|
||||
function createError(element, selector) {
|
||||
const error = new ExtensionError(`Performing querySelectorNotNull(element, ${JSON.stringify(selector)}) returned null`);
|
||||
error.data = {element, selector};
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {Element} T
|
||||
* @param {Element|Document|DocumentFragment} element
|
||||
* @param {string} selector
|
||||
* @returns {T}
|
||||
* @throws {Error}
|
||||
*/
|
||||
export function querySelectorNotNull(element, selector) {
|
||||
/** @type {?T} */
|
||||
const result = element.querySelector(selector);
|
||||
if (result === null) { throw createError(element, selector); }
|
||||
return result;
|
||||
}
|
||||
165
vendor/yomitan/js/dom/scroll-element.js
vendored
Normal file
165
vendor/yomitan/js/dom/scroll-element.js
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export class ScrollElement {
|
||||
/**
|
||||
* @param {Element} node
|
||||
*/
|
||||
constructor(node) {
|
||||
/** @type {Element} */
|
||||
this._node = node;
|
||||
/** @type {?number} */
|
||||
this._animationRequestId = null;
|
||||
/** @type {number} */
|
||||
this._animationStartTime = 0;
|
||||
/** @type {number} */
|
||||
this._animationStartX = 0;
|
||||
/** @type {number} */
|
||||
this._animationStartY = 0;
|
||||
/** @type {number} */
|
||||
this._animationEndTime = 0;
|
||||
/** @type {number} */
|
||||
this._animationEndX = 0;
|
||||
/** @type {number} */
|
||||
this._animationEndY = 0;
|
||||
/** @type {(time: number) => void} */
|
||||
this._requestAnimationFrameCallback = this._onAnimationFrame.bind(this);
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get x() {
|
||||
return this._node !== null ? this._node.scrollLeft : window.scrollX || window.pageXOffset;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get y() {
|
||||
return this._node !== null ? this._node.scrollTop : window.scrollY || window.pageYOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} y
|
||||
*/
|
||||
toY(y) {
|
||||
this.to(this.x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
*/
|
||||
toX(x) {
|
||||
this.to(x, this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
to(x, y) {
|
||||
this.stop();
|
||||
this._scroll(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} time
|
||||
*/
|
||||
animate(x, y, time) {
|
||||
this._animationStartX = this.x;
|
||||
this._animationStartY = this.y;
|
||||
this._animationStartTime = window.performance.now();
|
||||
this._animationEndX = x;
|
||||
this._animationEndY = y;
|
||||
this._animationEndTime = this._animationStartTime + time;
|
||||
this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback);
|
||||
}
|
||||
|
||||
/** */
|
||||
stop() {
|
||||
if (this._animationRequestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.cancelAnimationFrame(this._animationRequestId);
|
||||
this._animationRequestId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {DOMRect}
|
||||
*/
|
||||
getRect() {
|
||||
return this._node.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {number} time
|
||||
*/
|
||||
_onAnimationFrame(time) {
|
||||
if (time >= this._animationEndTime) {
|
||||
this._scroll(this._animationEndX, this._animationEndY);
|
||||
this._animationRequestId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const t = this._easeInOutCubic((time - this._animationStartTime) / (this._animationEndTime - this._animationStartTime));
|
||||
this._scroll(
|
||||
this._lerp(this._animationStartX, this._animationEndX, t),
|
||||
this._lerp(this._animationStartY, this._animationEndY, t),
|
||||
);
|
||||
|
||||
this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} t
|
||||
* @returns {number}
|
||||
*/
|
||||
_easeInOutCubic(t) {
|
||||
if (t < 0.5) {
|
||||
return (4 * t * t * t);
|
||||
} else {
|
||||
t = 1 - t;
|
||||
return 1 - (4 * t * t * t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @param {number} percent
|
||||
* @returns {number}
|
||||
*/
|
||||
_lerp(start, end, percent) {
|
||||
return (end - start) * percent + start;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
_scroll(x, y) {
|
||||
if (this._node !== null) {
|
||||
this._node.scrollLeft = x;
|
||||
this._node.scrollTop = y;
|
||||
} else {
|
||||
window.scroll(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
300
vendor/yomitan/js/dom/selector-observer.js
vendored
Normal file
300
vendor/yomitan/js/dom/selector-observer.js
vendored
Normal file
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class which is used to observe elements matching a selector in specific element.
|
||||
* @template [T=unknown]
|
||||
*/
|
||||
export class SelectorObserver {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
* @param {import('selector-observer').ConstructorDetails<T>} details The configuration for the object.
|
||||
*/
|
||||
constructor({
|
||||
selector,
|
||||
ignoreSelector = null,
|
||||
onAdded = null,
|
||||
onRemoved = null,
|
||||
onChildrenUpdated = null,
|
||||
isStale = null,
|
||||
}) {
|
||||
/** @type {string} */
|
||||
this._selector = selector;
|
||||
/** @type {?string} */
|
||||
this._ignoreSelector = ignoreSelector;
|
||||
/** @type {?import('selector-observer').OnAddedCallback<T>} */
|
||||
this._onAdded = onAdded;
|
||||
/** @type {?import('selector-observer').OnRemovedCallback<T>} */
|
||||
this._onRemoved = onRemoved;
|
||||
/** @type {?import('selector-observer').OnChildrenUpdatedCallback<T>} */
|
||||
this._onChildrenUpdated = onChildrenUpdated;
|
||||
/** @type {?import('selector-observer').IsStaleCallback<T>} */
|
||||
this._isStale = isStale;
|
||||
/** @type {?Element} */
|
||||
this._observingElement = null;
|
||||
/** @type {MutationObserver} */
|
||||
this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
|
||||
/** @type {Map<Node, import('selector-observer').Observer<T>>} */
|
||||
this._elementMap = new Map(); // Map([element => observer]...)
|
||||
/** @type {Map<Node, Set<import('selector-observer').Observer<T>>>} */
|
||||
this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)]...)
|
||||
/** @type {boolean} */
|
||||
this._isObserving = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not an element is currently being observed.
|
||||
* @returns {boolean} `true` if an element is being observed, `false` otherwise.
|
||||
*/
|
||||
get isObserving() {
|
||||
return this._observingElement !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts DOM mutation observing the target element.
|
||||
* @param {Element} element The element to observe changes in.
|
||||
* @param {boolean} [attributes] A boolean for whether or not attribute changes should be observed.
|
||||
* @throws {Error} An error if element is null.
|
||||
* @throws {Error} An error if an element is already being observed.
|
||||
*/
|
||||
observe(element, attributes = false) {
|
||||
if (element === null) {
|
||||
throw new Error('Invalid element');
|
||||
}
|
||||
if (this.isObserving) {
|
||||
throw new Error('Instance is already observing an element');
|
||||
}
|
||||
|
||||
this._observingElement = element;
|
||||
this._mutationObserver.observe(element, {
|
||||
attributes: !!attributes,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
const {parentNode} = element;
|
||||
this._onMutation([{
|
||||
type: 'childList',
|
||||
target: parentNode !== null ? parentNode : element,
|
||||
addedNodes: [element],
|
||||
removedNodes: [],
|
||||
}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops observing the target element.
|
||||
*/
|
||||
disconnect() {
|
||||
if (!this.isObserving) { return; }
|
||||
|
||||
this._mutationObserver.disconnect();
|
||||
this._observingElement = null;
|
||||
|
||||
for (const observer of this._elementMap.values()) {
|
||||
this._removeObserver(observer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterable list of [element, data] pairs.
|
||||
* @yields {[element: Element, data: T]} A sequence of [element, data] pairs.
|
||||
* @returns {Generator<[element: Element, data: T], void, unknown>}
|
||||
*/
|
||||
*entries() {
|
||||
for (const {element, data} of this._elementMap.values()) {
|
||||
yield [element, data];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterable list of data for every element.
|
||||
* @yields {T} A sequence of data values.
|
||||
* @returns {Generator<T, void, unknown>}
|
||||
*/
|
||||
*datas() {
|
||||
for (const {data} of this._elementMap.values()) {
|
||||
yield data;
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {(MutationRecord|import('selector-observer').MutationRecordLike)[]} mutationList
|
||||
*/
|
||||
_onMutation(mutationList) {
|
||||
for (const mutation of mutationList) {
|
||||
switch (mutation.type) {
|
||||
case 'childList':
|
||||
this._onChildListMutation(mutation);
|
||||
break;
|
||||
case 'attributes':
|
||||
this._onAttributeMutation(mutation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MutationRecord|import('selector-observer').MutationRecordLike} record
|
||||
*/
|
||||
_onChildListMutation({addedNodes, removedNodes, target}) {
|
||||
const selector = this._selector;
|
||||
const ELEMENT_NODE = Node.ELEMENT_NODE;
|
||||
|
||||
for (const node of removedNodes) {
|
||||
const observers = this._elementAncestorMap.get(node);
|
||||
if (typeof observers === 'undefined') { continue; }
|
||||
for (const observer of observers) {
|
||||
this._removeObserver(observer);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of addedNodes) {
|
||||
if (node.nodeType !== ELEMENT_NODE) { continue; }
|
||||
if (/** @type {Element} */ (node).matches(selector)) {
|
||||
this._createObserver(/** @type {Element} */ (node));
|
||||
}
|
||||
for (const childNode of /** @type {Element} */ (node).querySelectorAll(selector)) {
|
||||
this._createObserver(childNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this._onChildrenUpdated !== null &&
|
||||
(removedNodes.length > 0 || addedNodes.length > 0)
|
||||
) {
|
||||
for (let node = /** @type {?Node} */ (target); node !== null; node = node.parentNode) {
|
||||
const observer = this._elementMap.get(node);
|
||||
if (typeof observer !== 'undefined') {
|
||||
this._onObserverChildrenUpdated(observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MutationRecord|import('selector-observer').MutationRecordLike} record
|
||||
*/
|
||||
_onAttributeMutation({target}) {
|
||||
const selector = this._selector;
|
||||
const observers = this._elementAncestorMap.get(/** @type {Element} */ (target));
|
||||
if (typeof observers !== 'undefined') {
|
||||
for (const observer of observers) {
|
||||
const element = observer.element;
|
||||
if (
|
||||
!element.matches(selector) ||
|
||||
this._shouldIgnoreElement(element) ||
|
||||
this._isObserverStale(observer)
|
||||
) {
|
||||
this._removeObserver(observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (/** @type {Element} */ (target).matches(selector)) {
|
||||
this._createObserver(/** @type {Element} */ (target));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
*/
|
||||
_createObserver(element) {
|
||||
if (this._elementMap.has(element) || this._shouldIgnoreElement(element) || this._onAdded === null) { return; }
|
||||
|
||||
const data = this._onAdded(element);
|
||||
if (typeof data === 'undefined') { return; }
|
||||
const ancestors = this._getAncestors(element);
|
||||
const observer = {element, ancestors, data};
|
||||
|
||||
this._elementMap.set(element, observer);
|
||||
|
||||
for (const ancestor of ancestors) {
|
||||
let observers = this._elementAncestorMap.get(ancestor);
|
||||
if (typeof observers === 'undefined') {
|
||||
observers = new Set();
|
||||
this._elementAncestorMap.set(ancestor, observers);
|
||||
}
|
||||
observers.add(observer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('selector-observer').Observer<T>} observer
|
||||
*/
|
||||
_removeObserver(observer) {
|
||||
const {element, ancestors, data} = observer;
|
||||
|
||||
this._elementMap.delete(element);
|
||||
|
||||
for (const ancestor of ancestors) {
|
||||
const observers = this._elementAncestorMap.get(ancestor);
|
||||
if (typeof observers === 'undefined') { continue; }
|
||||
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) {
|
||||
this._elementAncestorMap.delete(ancestor);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._onRemoved !== null) {
|
||||
this._onRemoved(element, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('selector-observer').Observer<T>} observer
|
||||
*/
|
||||
_onObserverChildrenUpdated(observer) {
|
||||
if (this._onChildrenUpdated === null) { return; }
|
||||
this._onChildrenUpdated(observer.element, observer.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('selector-observer').Observer<T>} observer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isObserverStale(observer) {
|
||||
return (this._isStale !== null && this._isStale(observer.element, observer.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldIgnoreElement(element) {
|
||||
return (this._ignoreSelector !== null && element.matches(this._ignoreSelector));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @returns {Node[]}
|
||||
*/
|
||||
_getAncestors(node) {
|
||||
const root = this._observingElement;
|
||||
const results = [];
|
||||
let n = /** @type {?Node} */ (node);
|
||||
while (n !== null) {
|
||||
results.push(n);
|
||||
if (n === root) { break; }
|
||||
n = n.parentNode;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user