initial commit

This commit is contained in:
2026-02-09 19:04:19 -08:00
commit f92b57c7b6
531 changed files with 196294 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

View 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,
});
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
}
}
}

View 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();
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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);
}
}
}

View 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();

View 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
View 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';
});

View 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;
}
}

View 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; });
}
}

View 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';
}
}

View 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}`;
}
}

View 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';
});

View 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;
}
}
}

View 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;
}
}