mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 18:22:42 -08:00
initial commit
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user