aboutsummaryrefslogtreecommitdiff
path: root/ext/js/display
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2021-02-13 22:52:28 -0500
committerGitHub <noreply@github.com>2021-02-13 22:52:28 -0500
commit6a271e067fa917614f4c81f473533e24c6d04404 (patch)
tree0d81658b1c03aecfbba133425aefc0ea7612338c /ext/js/display
parentdeed5027cd18bcdb9cb9d13cb7831be0ec5384e8 (diff)
Move mixed/js (#1383)
* Move mixed/js/core.js to js/core.js * Move mixed/js/yomichan.js to js/yomichan.js * Move mixed/js/timer.js to js/debug/timer.js * Move mixed/js/hotkey-handler.js to js/input/hotkey-handler.js * Move mixed/js/hotkey-help-controller.js to js/input/hotkey-help-controller.js * Move mixed/js/hotkey-util.js to js/input/hotkey-util.js * Move mixed/js/audio-system.js to js/input/audio-system.js * Move mixed/js/media-loader.js to js/input/media-loader.js * Move mixed/js/text-to-speech-audio.js to js/input/text-to-speech-audio.js * Move mixed/js/comm.js to js/comm/cross-frame-api.js * Move mixed/js/api.js to js/comm/api.js * Move mixed/js/frame-client.js to js/comm/frame-client.js * Move mixed/js/frame-endpoint.js to js/comm/frame-endpoint.js * Move mixed/js/display.js to js/display/display.js * Move mixed/js/display-audio.js to js/display/display-audio.js * Move mixed/js/display-generator.js to js/display/display-generator.js * Move mixed/js/display-history.js to js/display/display-history.js * Move mixed/js/display-notification.js to js/display/display-notification.js * Move mixed/js/display-profile-selection.js to js/display/display-profile-selection.js * Move mixed/js/japanese.js to js/language/japanese-util.js * Move mixed/js/dictionary-data-util.js to js/language/dictionary-data-util.js * Move mixed/js/document-focus-controller.js to js/dom/document-focus-controller.js * Move mixed/js/document-util.js to js/dom/document-util.js * Move mixed/js/dom-data-binder.js to js/dom/dom-data-binder.js * Move mixed/js/html-template-collection.js to js/dom/html-template-collection.js * Move mixed/js/panel-element.js to js/dom/panel-element.js * Move mixed/js/popup-menu.js to js/dom/popup-menu.js * Move mixed/js/selector-observer.js to js/dom/selector-observer.js * Move mixed/js/scroll.js to js/dom/window-scroll.js * Move mixed/js/text-scanner.js to js/language/text-scanner.js * Move mixed/js/cache-map.js to js/general/cache-map.js * Move mixed/js/object-property-accessor.js to js/general/object-property-accessor.js * Move mixed/js/task-accumulator.js to js/general/task-accumulator.js * Move mixed/js/environment.js to js/background/environment.js * Move mixed/js/dynamic-loader.js to js/scripting/dynamic-loader.js * Move mixed/js/dynamic-loader-sentinel.js to js/scripting/dynamic-loader-sentinel.js
Diffstat (limited to 'ext/js/display')
-rw-r--r--ext/js/display/display-audio.js544
-rw-r--r--ext/js/display/display-generator.js702
-rw-r--r--ext/js/display/display-history.js178
-rw-r--r--ext/js/display/display-notification.js95
-rw-r--r--ext/js/display/display-profile-selection.js106
-rw-r--r--ext/js/display/display.js1886
6 files changed, 3511 insertions, 0 deletions
diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js
new file mode 100644
index 00000000..f624d85b
--- /dev/null
+++ b/ext/js/display/display-audio.js
@@ -0,0 +1,544 @@
+/*
+ * Copyright (C) 2021 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/>.
+ */
+
+/* global
+ * AudioSystem
+ * PopupMenu
+ * api
+ */
+
+class DisplayAudio {
+ constructor(display) {
+ this._display = display;
+ this._audioPlaying = null;
+ this._audioSystem = new AudioSystem();
+ this._autoPlayAudioTimer = null;
+ this._autoPlayAudioDelay = 400;
+ this._eventListeners = new EventListenerCollection();
+ this._cache = new Map();
+ this._menuContainer = document.querySelector('#popup-menus');
+ }
+
+ get autoPlayAudioDelay() {
+ return this._autoPlayAudioDelay;
+ }
+
+ set autoPlayAudioDelay(value) {
+ this._autoPlayAudioDelay = value;
+ }
+
+ prepare() {
+ this._audioSystem.prepare();
+ }
+
+ updateOptions(options) {
+ const data = document.documentElement.dataset;
+ data.audioEnabled = `${options.audio.enabled && options.audio.sources.length > 0}`;
+ }
+
+ cleanupEntries() {
+ this._cache.clear();
+ this.clearAutoPlayTimer();
+ this._eventListeners.removeAllEventListeners();
+ }
+
+ setupEntry(entry, definitionIndex) {
+ for (const button of entry.querySelectorAll('.action-play-audio')) {
+ const expressionIndex = this._getAudioPlayButtonExpressionIndex(button);
+ this._eventListeners.addEventListener(button, 'click', this._onAudioPlayButtonClick.bind(this, definitionIndex, expressionIndex), false);
+ this._eventListeners.addEventListener(button, 'contextmenu', this._onAudioPlayButtonContextMenu.bind(this, definitionIndex, expressionIndex), false);
+ this._eventListeners.addEventListener(button, 'menuClose', this._onAudioPlayMenuCloseClick.bind(this, definitionIndex, expressionIndex), false);
+ }
+ }
+
+ setupEntriesComplete() {
+ const audioOptions = this._getAudioOptions();
+ if (!audioOptions.enabled || !audioOptions.autoPlay) { return; }
+
+ this.clearAutoPlayTimer();
+
+ const definitions = this._display.definitions;
+ if (definitions.length === 0) { return; }
+
+ const firstDefinition = definitions[0];
+ if (firstDefinition.type === 'kanji') { return; }
+
+ const callback = () => {
+ this._autoPlayAudioTimer = null;
+ this.playAudio(0, 0);
+ };
+
+ if (this._autoPlayAudioDelay > 0) {
+ this._autoPlayAudioTimer = setTimeout(callback, this._autoPlayAudioDelay);
+ } else {
+ callback();
+ }
+ }
+
+ clearAutoPlayTimer() {
+ if (this._autoPlayAudioTimer === null) { return; }
+ clearTimeout(this._autoPlayAudioTimer);
+ this._autoPlayAudioTimer = null;
+ }
+
+ stopAudio() {
+ if (this._audioPlaying === null) { return; }
+ this._audioPlaying.pause();
+ this._audioPlaying = null;
+ }
+
+ async playAudio(definitionIndex, expressionIndex, sources=null, sourceDetailsMap=null) {
+ this.stopAudio();
+ this.clearAutoPlayTimer();
+
+ const expressionReading = this._getExpressionAndReading(definitionIndex, expressionIndex);
+ if (expressionReading === null) { return; }
+
+ const buttons = this._getAudioPlayButtons(definitionIndex, expressionIndex);
+
+ const {expression, reading} = expressionReading;
+ const audioOptions = this._getAudioOptions();
+ const {textToSpeechVoice, customSourceUrl, customSourceType, volume} = audioOptions;
+ if (!Array.isArray(sources)) {
+ ({sources} = audioOptions);
+ }
+ if (!(sourceDetailsMap instanceof Map)) {
+ sourceDetailsMap = null;
+ }
+
+ const progressIndicatorVisible = this._display.progressIndicatorVisible;
+ const overrideToken = progressIndicatorVisible.setOverride(true);
+ try {
+ // Create audio
+ let audio;
+ let title;
+ const info = await this._createExpressionAudio(sources, sourceDetailsMap, expression, reading, {textToSpeechVoice, customSourceUrl, customSourceType});
+ if (info !== null) {
+ let source;
+ ({audio, source} = info);
+ const sourceIndex = sources.indexOf(source);
+ title = `From source ${1 + sourceIndex}: ${source}`;
+ } else {
+ audio = this._audioSystem.getFallbackAudio();
+ title = 'Could not find audio';
+ }
+
+ // Stop any currently playing audio
+ this.stopAudio();
+
+ // Update details
+ const potentialAvailableAudioCount = this._getPotentialAvailableAudioCount(expression, reading);
+ for (const button of buttons) {
+ const titleDefault = button.dataset.titleDefault || '';
+ button.title = `${titleDefault}\n${title}`;
+ this._updateAudioPlayButtonBadge(button, potentialAvailableAudioCount);
+ }
+
+ // Play
+ audio.currentTime = 0;
+ audio.volume = Number.isFinite(volume) ? Math.max(0.0, Math.min(1.0, volume / 100.0)) : 1.0;
+
+ const playPromise = audio.play();
+ this._audioPlaying = audio;
+
+ if (typeof playPromise !== 'undefined') {
+ try {
+ await playPromise;
+ } catch (e) {
+ // NOP
+ }
+ }
+ } finally {
+ progressIndicatorVisible.clearOverride(overrideToken);
+ }
+ }
+
+ // Private
+
+ _onAudioPlayButtonClick(definitionIndex, expressionIndex, e) {
+ e.preventDefault();
+
+ if (e.shiftKey) {
+ this._showAudioMenu(e.currentTarget, definitionIndex, expressionIndex);
+ } else {
+ this.playAudio(definitionIndex, expressionIndex);
+ }
+ }
+
+ _onAudioPlayButtonContextMenu(definitionIndex, expressionIndex, e) {
+ e.preventDefault();
+
+ this._showAudioMenu(e.currentTarget, definitionIndex, expressionIndex);
+ }
+
+ _onAudioPlayMenuCloseClick(definitionIndex, expressionIndex, e) {
+ const {detail: {action, item}} = e;
+ switch (action) {
+ case 'playAudioFromSource':
+ {
+ const {source, index} = item.dataset;
+ let sourceDetailsMap = null;
+ if (typeof index !== 'undefined') {
+ const index2 = Number.parseInt(index, 10);
+ sourceDetailsMap = new Map([
+ [source, {start: index2, end: index2 + 1}]
+ ]);
+ }
+ this.playAudio(definitionIndex, expressionIndex, [source], sourceDetailsMap);
+ }
+ break;
+ }
+ }
+
+ _getAudioPlayButtonExpressionIndex(button) {
+ const expressionNode = button.closest('.term-expression');
+ if (expressionNode !== null) {
+ const expressionIndex = parseInt(expressionNode.dataset.index, 10);
+ if (Number.isFinite(expressionIndex)) { return expressionIndex; }
+ }
+ return 0;
+ }
+
+ _getAudioPlayButtons(definitionIndex, expressionIndex) {
+ const results = [];
+ const {definitionNodes} = this._display;
+ if (definitionIndex >= 0 && definitionIndex < definitionNodes.length) {
+ const node = definitionNodes[definitionIndex];
+ const button1 = (expressionIndex === 0 ? node.querySelector('.action-play-audio') : null);
+ const button2 = node.querySelector(`.term-expression:nth-of-type(${expressionIndex + 1}) .action-play-audio`);
+ if (button1 !== null) { results.push(button1); }
+ if (button2 !== null) { results.push(button2); }
+ }
+ return results;
+ }
+
+ async _createExpressionAudio(sources, sourceDetailsMap, expression, reading, details) {
+ const key = this._getExpressionReadingKey(expression, reading);
+
+ let sourceMap = this._cache.get(key);
+ if (typeof sourceMap === 'undefined') {
+ sourceMap = new Map();
+ this._cache.set(key, sourceMap);
+ }
+
+ for (let i = 0, ii = sources.length; i < ii; ++i) {
+ const source = sources[i];
+
+ let infoListPromise;
+ let sourceInfo = sourceMap.get(source);
+ if (typeof sourceInfo === 'undefined') {
+ infoListPromise = this._getExpressionAudioInfoList(source, expression, reading, details);
+ sourceInfo = {infoListPromise, infoList: null};
+ sourceMap.set(source, sourceInfo);
+ }
+
+ let {infoList} = sourceInfo;
+ if (infoList === null) {
+ infoList = await infoListPromise;
+ sourceInfo.infoList = infoList;
+ }
+
+ let start = 0;
+ let end = infoList.length;
+
+ if (sourceDetailsMap !== null) {
+ const sourceDetails = sourceDetailsMap.get(source);
+ if (typeof sourceDetails !== 'undefined') {
+ const {start: start2, end: end2} = sourceDetails;
+ if (this._isInteger(start2)) { start = this._clamp(start2, start, end); }
+ if (this._isInteger(end2)) { end = this._clamp(end2, start, end); }
+ }
+ }
+
+ const audio = await this._createAudioFromInfoList(source, infoList, start, end);
+ if (audio !== null) { return audio; }
+ }
+
+ return null;
+ }
+
+ async _createAudioFromInfoList(source, infoList, start, end) {
+ for (let i = start; i < end; ++i) {
+ const item = infoList[i];
+
+ let {audio, audioResolved} = item;
+
+ if (!audioResolved) {
+ let {audioPromise} = item;
+ if (audioPromise === null) {
+ audioPromise = this._createAudioFromInfo(item.info, source);
+ item.audioPromise = audioPromise;
+ }
+
+ try {
+ audio = await audioPromise;
+ } catch (e) {
+ continue;
+ } finally {
+ item.audioResolved = true;
+ }
+
+ item.audio = audio;
+ }
+
+ if (audio === null) { continue; }
+
+ return {audio, source, infoListIndex: i};
+ }
+ return null;
+ }
+
+ async _createAudioFromInfo(info, source) {
+ switch (info.type) {
+ case 'url':
+ return await this._audioSystem.createAudio(info.url, source);
+ case 'tts':
+ return this._audioSystem.createTextToSpeechAudio(info.text, info.voice);
+ default:
+ throw new Error(`Unsupported type: ${info.type}`);
+ }
+ }
+
+ async _getExpressionAudioInfoList(source, expression, reading, details) {
+ const infoList = await api.getExpressionAudioInfoList(source, expression, reading, details);
+ return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
+ }
+
+ _getExpressionAndReading(definitionIndex, expressionIndex) {
+ const {definitions} = this._display;
+ if (definitionIndex < 0 || definitionIndex >= definitions.length) { return null; }
+
+ const definition = definitions[definitionIndex];
+ if (definition.type === 'kanji') { return null; }
+
+ const {expressions} = definition;
+ if (expressionIndex < 0 || expressionIndex >= expressions.length) { return null; }
+
+ const {expression, reading} = expressions[expressionIndex];
+ return {expression, reading};
+ }
+
+ _getExpressionReadingKey(expression, reading) {
+ return JSON.stringify([expression, reading]);
+ }
+
+ _getAudioOptions() {
+ return this._display.getOptions().audio;
+ }
+
+ _isInteger(value) {
+ return (
+ typeof value === 'number' &&
+ Number.isFinite(value) &&
+ Math.floor(value) === value
+ );
+ }
+
+ _clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ _updateAudioPlayButtonBadge(button, potentialAvailableAudioCount) {
+ if (potentialAvailableAudioCount === null) {
+ delete button.dataset.potentialAvailableAudioCount;
+ } else {
+ button.dataset.potentialAvailableAudioCount = `${potentialAvailableAudioCount}`;
+ }
+
+ const badge = button.querySelector('.action-button-badge');
+ if (badge === null) { return; }
+
+ const badgeData = badge.dataset;
+ switch (potentialAvailableAudioCount) {
+ case 0:
+ badgeData.icon = 'cross';
+ badgeData.hidden = false;
+ break;
+ case 1:
+ case null:
+ delete badgeData.icon;
+ badgeData.hidden = true;
+ break;
+ default:
+ badgeData.icon = 'plus-thick';
+ badgeData.hidden = false;
+ break;
+ }
+ }
+
+ _getPotentialAvailableAudioCount(expression, reading) {
+ const key = this._getExpressionReadingKey(expression, reading);
+ const sourceMap = this._cache.get(key);
+ if (typeof sourceMap === 'undefined') { return null; }
+
+ let count = 0;
+ for (const {infoList} of sourceMap.values()) {
+ if (infoList === null) { continue; }
+ for (const {audio, audioResolved} of infoList) {
+ if (!audioResolved || audio !== null) {
+ ++count;
+ }
+ }
+ }
+ return count;
+ }
+
+ _showAudioMenu(button, definitionIndex, expressionIndex) {
+ const expressionReading = this._getExpressionAndReading(definitionIndex, expressionIndex);
+ if (expressionReading === null) { return; }
+
+ const {expression, reading} = expressionReading;
+ const popupMenu = this._createMenu(button, expression, reading);
+ popupMenu.prepare();
+ }
+
+ _createMenu(button, expression, reading) {
+ // Options
+ const {sources, textToSpeechVoice, customSourceUrl} = this._getAudioOptions();
+ const sourceIndexMap = new Map();
+ for (let i = 0, ii = sources.length; i < ii; ++i) {
+ sourceIndexMap.set(sources[i], i);
+ }
+
+ // Create menu
+ const menuNode = this._display.displayGenerator.createPopupMenu('audio-button');
+
+ // Create menu item metadata
+ const menuItems = [];
+ const menuItemNodes = menuNode.querySelectorAll('.popup-menu-item');
+ for (let i = 0, ii = menuItemNodes.length; i < ii; ++i) {
+ const node = menuItemNodes[i];
+ const {source} = node.dataset;
+ let optionsIndex = sourceIndexMap.get(source);
+ if (typeof optionsIndex === 'undefined') { optionsIndex = null; }
+ menuItems.push({node, source, index: i, optionsIndex});
+ }
+
+ // Sort according to source order in options
+ menuItems.sort((a, b) => {
+ const ai = a.optionsIndex;
+ const bi = b.optionsIndex;
+ if (ai !== null) {
+ if (bi !== null) {
+ const i = ai - bi;
+ if (i !== 0) { return i; }
+ } else {
+ return -1;
+ }
+ } else {
+ if (bi !== null) {
+ return 1;
+ }
+ }
+ return a.index - b.index;
+ });
+
+ // Set up items based on cache data
+ const sourceMap = this._cache.get(this._getExpressionReadingKey(expression, reading));
+ const menuEntryMap = new Map();
+ let showIcons = false;
+ for (let i = 0, ii = menuItems.length; i < ii; ++i) {
+ const {node, source, optionsIndex} = menuItems[i];
+ const entries = this._getMenuItemEntries(node, sourceMap, source);
+ menuEntryMap.set(source, entries);
+ for (const {node: node2, valid, index} of entries) {
+ if (valid !== null) {
+ const icon = node2.querySelector('.popup-menu-item-icon');
+ icon.dataset.icon = valid ? 'checkmark' : 'cross';
+ showIcons = true;
+ }
+ if (index !== null) {
+ node2.dataset.index = `${index}`;
+ }
+ node2.dataset.valid = `${valid}`;
+ node2.dataset.sourceInOptions = `${optionsIndex !== null}`;
+ node2.style.order = `${i}`;
+ }
+ }
+ menuNode.dataset.showIcons = `${showIcons}`;
+
+ // Hide options
+ if (textToSpeechVoice.length === 0) {
+ this._setMenuItemEntriesHidden(menuEntryMap, 'text-to-speech', true);
+ this._setMenuItemEntriesHidden(menuEntryMap, 'text-to-speech-reading', true);
+ }
+ if (customSourceUrl.length === 0) {
+ this._setMenuItemEntriesHidden(menuEntryMap, 'custom', true);
+ }
+
+ // Create popup menu
+ this._menuContainer.appendChild(menuNode);
+ return new PopupMenu(button, menuNode);
+ }
+
+ _getMenuItemEntries(node, sourceMap, source) {
+ const entries = [{node, valid: null, index: null}];
+
+ const nextNode = node.nextSibling;
+
+ if (typeof sourceMap === 'undefined') { return entries; }
+
+ const sourceInfo = sourceMap.get(source);
+ if (typeof sourceInfo === 'undefined') { return entries; }
+
+ const {infoList} = sourceInfo;
+ if (infoList === null) { return entries; }
+
+ if (infoList.length === 0) {
+ entries[0].valid = false;
+ return entries;
+ }
+
+ const defaultLabel = node.querySelector('.popup-menu-item-label').textContent;
+
+ for (let i = 0, ii = infoList.length; i < ii; ++i) {
+ // Get/create entry
+ let entry;
+ if (i < entries.length) {
+ entry = entries[i];
+ } else {
+ const node2 = node.cloneNode(true);
+ nextNode.parentNode.insertBefore(node2, nextNode);
+ entry = {node: node2, valid: null, index: null};
+ entries.push(entry);
+ }
+
+ // Entry info
+ entry.index = i;
+
+ const {audio, audioResolved, info: {name}} = infoList[i];
+ if (audioResolved) { entry.valid = (audio !== null); }
+
+ const labelNode = entry.node.querySelector('.popup-menu-item-label');
+ let label = defaultLabel;
+ if (ii > 1) { label = `${label} ${i + 1}`; }
+ if (typeof name === 'string' && name.length > 0) { label += `: ${name}`; }
+ labelNode.textContent = label;
+ }
+
+ return entries;
+ }
+
+ _setMenuItemEntriesHidden(menuEntryMap, source, hidden) {
+ const entries = menuEntryMap.get(source);
+ if (typeof entries === 'undefined') { return; }
+
+ for (const {node} of entries) {
+ node.hidden = hidden;
+ }
+ }
+}
diff --git a/ext/js/display/display-generator.js b/ext/js/display/display-generator.js
new file mode 100644
index 00000000..05376ee5
--- /dev/null
+++ b/ext/js/display/display-generator.js
@@ -0,0 +1,702 @@
+/*
+ * Copyright (C) 2019-2021 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 <http://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DictionaryDataUtil
+ * HtmlTemplateCollection
+ * api
+ */
+
+class DisplayGenerator {
+ constructor({japaneseUtil, mediaLoader, hotkeyHelpController=null}) {
+ this._japaneseUtil = japaneseUtil;
+ this._mediaLoader = mediaLoader;
+ this._hotkeyHelpController = hotkeyHelpController;
+ this._templates = null;
+ this._termPitchAccentStaticTemplateIsSetup = false;
+ }
+
+ async prepare() {
+ const html = await api.getDisplayTemplatesHtml();
+ this._templates = new HtmlTemplateCollection(html);
+ this.updateHotkeys();
+ }
+
+ updateHotkeys() {
+ const hotkeyHelpController = this._hotkeyHelpController;
+ if (hotkeyHelpController === null) { return; }
+ for (const template of this._templates.getAllTemplates()) {
+ hotkeyHelpController.setupNode(template.content);
+ }
+ }
+
+ preparePitchAccents() {
+ if (this._termPitchAccentStaticTemplateIsSetup) { return; }
+ this._termPitchAccentStaticTemplateIsSetup = true;
+ const t = this._templates.instantiate('term-pitch-accent-static');
+ document.head.appendChild(t);
+ }
+
+ createTermEntry(details) {
+ const node = this._templates.instantiate('term-entry');
+
+ const expressionsContainer = node.querySelector('.term-expression-list');
+ const reasonsContainer = node.querySelector('.term-reasons');
+ const pitchesContainer = node.querySelector('.term-pitch-accent-group-list');
+ const frequencyGroupListContainer = node.querySelector('.frequency-group-list');
+ const definitionsContainer = node.querySelector('.term-definition-list');
+ const termTagsContainer = node.querySelector('.term-tags');
+
+ const {expressions, type, reasons, frequencies} = details;
+ const definitions = (type === 'term' ? [details] : details.definitions);
+ const merged = (type === 'termMerged' || type === 'termMergedByGlossary');
+ const pitches = DictionaryDataUtil.getPitchAccentInfos(details);
+ const pitchCount = pitches.reduce((i, v) => i + v.pitches.length, 0);
+ const groupedFrequencies = DictionaryDataUtil.groupTermFrequencies(frequencies);
+ const termTags = DictionaryDataUtil.groupTermTags(details);
+
+ const uniqueExpressions = new Set();
+ const uniqueReadings = new Set();
+ for (const {expression, reading} of expressions) {
+ uniqueExpressions.add(expression);
+ uniqueReadings.add(reading);
+ }
+
+ node.dataset.format = type;
+ node.dataset.expressionMulti = `${merged}`;
+ node.dataset.expressionCount = `${expressions.length}`;
+ node.dataset.definitionCount = `${definitions.length}`;
+ node.dataset.pitchAccentDictionaryCount = `${pitches.length}`;
+ node.dataset.pitchAccentCount = `${pitchCount}`;
+ node.dataset.uniqueExpressionCount = `${uniqueExpressions.size}`;
+ node.dataset.uniqueReadingCount = `${uniqueReadings.size}`;
+ node.dataset.frequencyCount = `${frequencies.length}`;
+ node.dataset.groupedFrequencyCount = `${groupedFrequencies.length}`;
+
+ this._appendMultiple(expressionsContainer, this._createTermExpression.bind(this), expressions);
+ this._appendMultiple(reasonsContainer, this._createTermReason.bind(this), reasons);
+ this._appendMultiple(frequencyGroupListContainer, this._createFrequencyGroup.bind(this), groupedFrequencies, false);
+ this._appendMultiple(pitchesContainer, this._createPitches.bind(this), pitches);
+ this._appendMultiple(termTagsContainer, this._createTermTag.bind(this), termTags, expressions.length);
+
+ // Add definitions
+ const dictionaryTag = this._createDictionaryTag(null);
+ for (let i = 0, ii = definitions.length; i < ii; ++i) {
+ const definition = definitions[i];
+ const {dictionary} = definition;
+
+ if (dictionaryTag.dictionary === dictionary) {
+ dictionaryTag.redundant = true;
+ } else {
+ dictionaryTag.redundant = false;
+ dictionaryTag.dictionary = dictionary;
+ dictionaryTag.name = dictionary;
+ }
+
+ const node2 = this._createTermDefinitionItem(definition, dictionaryTag);
+ node2.dataset.index = `${i}`;
+ definitionsContainer.appendChild(node2);
+ }
+ definitionsContainer.dataset.count = `${definitions.length}`;
+
+ return node;
+ }
+
+ createKanjiEntry(details) {
+ const node = this._templates.instantiate('kanji-entry');
+
+ const glyphContainer = node.querySelector('.kanji-glyph');
+ const frequencyGroupListContainer = node.querySelector('.frequency-group-list');
+ const tagContainer = node.querySelector('.tags');
+ const glossaryContainer = node.querySelector('.kanji-glossary-list');
+ const chineseReadingsContainer = node.querySelector('.kanji-readings-chinese');
+ const japaneseReadingsContainer = node.querySelector('.kanji-readings-japanese');
+ const statisticsContainer = node.querySelector('.kanji-statistics');
+ const classificationsContainer = node.querySelector('.kanji-classifications');
+ const codepointsContainer = node.querySelector('.kanji-codepoints');
+ const dictionaryIndicesContainer = node.querySelector('.kanji-dictionary-indices');
+
+ this._setTextContent(glyphContainer, details.character, 'ja');
+ const groupedFrequencies = DictionaryDataUtil.groupKanjiFrequencies(details.frequencies);
+
+ const dictionaryTag = this._createDictionaryTag(details.dictionary);
+
+ this._appendMultiple(frequencyGroupListContainer, this._createFrequencyGroup.bind(this), groupedFrequencies, true);
+ this._appendMultiple(tagContainer, this._createTag.bind(this), [...details.tags, dictionaryTag]);
+ this._appendMultiple(glossaryContainer, this._createKanjiGlossaryItem.bind(this), details.glossary);
+ this._appendMultiple(chineseReadingsContainer, this._createKanjiReading.bind(this), details.onyomi);
+ this._appendMultiple(japaneseReadingsContainer, this._createKanjiReading.bind(this), details.kunyomi);
+
+ statisticsContainer.appendChild(this._createKanjiInfoTable(details.stats.misc));
+ classificationsContainer.appendChild(this._createKanjiInfoTable(details.stats.class));
+ codepointsContainer.appendChild(this._createKanjiInfoTable(details.stats.code));
+ dictionaryIndicesContainer.appendChild(this._createKanjiInfoTable(details.stats.index));
+
+ return node;
+ }
+
+ createEmptyFooterNotification() {
+ return this._templates.instantiate('footer-notification');
+ }
+
+ createTagFooterNotificationDetails(tagNode) {
+ const node = this._templates.instantiateFragment('footer-notification-tag-details');
+
+ const details = tagNode.dataset.details;
+ this._setTextContent(node.querySelector('.tag-details'), details);
+
+ let disambiguation = null;
+ try {
+ let a = tagNode.dataset.disambiguation;
+ if (typeof a !== 'undefined') {
+ a = JSON.parse(a);
+ if (Array.isArray(a)) { disambiguation = a; }
+ }
+ } catch (e) {
+ // NOP
+ }
+
+ if (disambiguation !== null) {
+ const disambiguationContainer = node.querySelector('.tag-details-disambiguation-list');
+ const copyAttributes = ['totalExpressionCount', 'matchedExpressionCount', 'unmatchedExpressionCount'];
+ for (const attribute of copyAttributes) {
+ const value = tagNode.dataset[attribute];
+ if (typeof value === 'undefined') { continue; }
+ disambiguationContainer.dataset[attribute] = value;
+ }
+ for (const {expression, reading} of disambiguation) {
+ const segments = this._japaneseUtil.distributeFurigana(expression, reading);
+ const disambiguationItem = document.createElement('span');
+ disambiguationItem.className = 'tag-details-disambiguation';
+ disambiguationItem.lang = 'ja';
+ this._appendFurigana(disambiguationItem, segments, (container, text) => {
+ container.appendChild(document.createTextNode(text));
+ });
+ disambiguationContainer.appendChild(disambiguationItem);
+ }
+ }
+
+ return node;
+ }
+
+ createAnkiNoteErrorsNotificationContent(errors) {
+ const content = this._templates.instantiate('footer-notification-anki-errors-content');
+
+ const header = content.querySelector('.anki-note-error-header');
+ this._setTextContent(header, (errors.length === 1 ? 'An error occurred:' : `${errors.length} errors occurred:`), 'en');
+
+ const list = content.querySelector('.anki-note-error-list');
+ for (const error of errors) {
+ const div = document.createElement('li');
+ div.className = 'anki-note-error-message';
+ this._setTextContent(div, isObject(error) && typeof error.message === 'string' ? error.message : `${error}`);
+ list.appendChild(div);
+ }
+
+ return content;
+ }
+
+ createProfileListItem() {
+ return this._templates.instantiate('profile-list-item');
+ }
+
+ createPopupMenu(name) {
+ return this._templates.instantiate(`${name}-popup-menu`);
+ }
+
+ // Private
+
+ _createTermExpression(details) {
+ const {termFrequency, furiganaSegments, expression, reading, termTags} = details;
+
+ const searchQueries = [];
+ if (expression) { searchQueries.push(expression); }
+ if (reading) { searchQueries.push(reading); }
+
+ const node = this._templates.instantiate('term-expression');
+
+ const expressionContainer = node.querySelector('.term-expression-text');
+ const tagContainer = node.querySelector('.tags');
+
+ node.dataset.readingIsSame = `${!reading || reading === expression}`;
+ node.dataset.frequency = termFrequency;
+
+ expressionContainer.lang = 'ja';
+
+ this._appendFurigana(expressionContainer, furiganaSegments, this._appendKanjiLinks.bind(this));
+ this._appendMultiple(tagContainer, this._createTag.bind(this), termTags);
+ this._appendMultiple(tagContainer, this._createSearchTag.bind(this), searchQueries);
+
+ return node;
+ }
+
+ _createTermReason(reason) {
+ const fragment = this._templates.instantiateFragment('term-reason');
+ const node = fragment.querySelector('.term-reason');
+ this._setTextContent(node, reason);
+ node.dataset.reason = reason;
+ return fragment;
+ }
+
+ _createTermDefinitionItem(details, dictionaryTag) {
+ const node = this._templates.instantiate('term-definition-item');
+
+ const tagListContainer = node.querySelector('.term-definition-tag-list');
+ const onlyListContainer = node.querySelector('.term-definition-disambiguation-list');
+ const glossaryContainer = node.querySelector('.term-glossary-list');
+
+ const {dictionary, definitionTags} = details;
+ node.dataset.dictionary = dictionary;
+
+ this._appendMultiple(tagListContainer, this._createTag.bind(this), [...definitionTags, dictionaryTag]);
+ this._appendMultiple(onlyListContainer, this._createTermDisambiguation.bind(this), details.only);
+ this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary, dictionary);
+
+ return node;
+ }
+
+ _createTermGlossaryItem(glossary, dictionary) {
+ if (typeof glossary === 'string') {
+ return this._createTermGlossaryItemText(glossary);
+ } else if (typeof glossary === 'object' && glossary !== null) {
+ switch (glossary.type) {
+ case 'image':
+ return this._createTermGlossaryItemImage(glossary, dictionary);
+ }
+ }
+
+ return null;
+ }
+
+ _createTermGlossaryItemText(glossary) {
+ const node = this._templates.instantiate('term-glossary-item');
+ const container = node.querySelector('.term-glossary');
+ this._setTextContent(container, glossary);
+ return node;
+ }
+
+ _createTermGlossaryItemImage(data, dictionary) {
+ const {path, width, height, preferredWidth, preferredHeight, title, description, pixelated} = data;
+
+ const usedWidth = (
+ typeof preferredWidth === 'number' ?
+ preferredWidth :
+ width
+ );
+ const aspectRatio = (
+ typeof preferredWidth === 'number' &&
+ typeof preferredHeight === 'number' ?
+ preferredWidth / preferredHeight :
+ width / height
+ );
+
+ const node = this._templates.instantiate('term-glossary-item-image');
+ node.dataset.path = path;
+ node.dataset.dictionary = dictionary;
+ node.dataset.imageLoadState = 'not-loaded';
+
+ const imageContainer = node.querySelector('.term-glossary-image-container');
+ imageContainer.style.width = `${usedWidth}em`;
+ if (typeof title === 'string') {
+ imageContainer.title = title;
+ }
+
+ const aspectRatioSizer = node.querySelector('.term-glossary-image-aspect-ratio-sizer');
+ aspectRatioSizer.style.paddingTop = `${aspectRatio * 100.0}%`;
+
+ const image = node.querySelector('img.term-glossary-image');
+ const imageLink = node.querySelector('.term-glossary-image-link');
+ image.dataset.pixelated = `${pixelated === true}`;
+
+ if (this._mediaLoader !== null) {
+ this._mediaLoader.loadMedia(
+ path,
+ dictionary,
+ (url) => this._setImageData(node, image, imageLink, url, false),
+ () => this._setImageData(node, image, imageLink, null, true)
+ );
+ }
+
+ if (typeof description === 'string') {
+ const container = node.querySelector('.term-glossary-image-description');
+ this._setTextContent(container, description);
+ }
+
+ return node;
+ }
+
+ _setImageData(container, image, imageLink, url, unloaded) {
+ if (url !== null) {
+ image.src = url;
+ imageLink.href = url;
+ container.dataset.imageLoadState = 'loaded';
+ } else {
+ image.removeAttribute('src');
+ imageLink.removeAttribute('href');
+ container.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error';
+ }
+ }
+
+ _createTermDisambiguation(disambiguation) {
+ const node = this._templates.instantiate('term-definition-disambiguation');
+ node.dataset.term = disambiguation;
+ this._setTextContent(node, disambiguation, 'ja');
+ return node;
+ }
+
+ _createKanjiLink(character) {
+ const node = document.createElement('a');
+ node.className = 'kanji-link';
+ this._setTextContent(node, character, 'ja');
+ return node;
+ }
+
+ _createKanjiGlossaryItem(glossary) {
+ const node = this._templates.instantiate('kanji-glossary-item');
+ const container = node.querySelector('.kanji-glossary');
+ this._setTextContent(container, glossary);
+ return node;
+ }
+
+ _createKanjiReading(reading) {
+ const node = this._templates.instantiate('kanji-reading');
+ this._setTextContent(node, reading, 'ja');
+ return node;
+ }
+
+ _createKanjiInfoTable(details) {
+ const node = this._templates.instantiate('kanji-info-table');
+ const container = node.querySelector('.kanji-info-table-body');
+
+ const count = this._appendMultiple(container, this._createKanjiInfoTableItem.bind(this), details);
+ if (count === 0) {
+ const n = this._createKanjiInfoTableItemEmpty();
+ container.appendChild(n);
+ }
+
+ return node;
+ }
+
+ _createKanjiInfoTableItem(details) {
+ const node = this._templates.instantiate('kanji-info-table-item');
+ const nameNode = node.querySelector('.kanji-info-table-item-header');
+ const valueNode = node.querySelector('.kanji-info-table-item-value');
+ this._setTextContent(nameNode, details.notes || details.name);
+ this._setTextContent(valueNode, details.value);
+ return node;
+ }
+
+ _createKanjiInfoTableItemEmpty() {
+ return this._templates.instantiate('kanji-info-table-empty');
+ }
+
+ _createTag(details) {
+ const node = this._templates.instantiate('tag');
+
+ const inner = node.querySelector('.tag-inner');
+
+ node.title = details.notes;
+ this._setTextContent(inner, details.name);
+ node.dataset.details = details.notes || details.name;
+ node.dataset.category = details.category;
+ if (details.redundant) { node.dataset.redundant = 'true'; }
+
+ return node;
+ }
+
+ _createTermTag(details, totalExpressionCount) {
+ const {tag, expressions} = details;
+ const node = this._createTag(tag);
+ node.dataset.disambiguation = `${JSON.stringify(expressions)}`;
+ node.dataset.totalExpressionCount = `${totalExpressionCount}`;
+ node.dataset.matchedExpressionCount = `${expressions.length}`;
+ node.dataset.unmatchedExpressionCount = `${Math.max(0, totalExpressionCount - expressions.length)}`;
+ return node;
+ }
+
+ _createSearchTag(text) {
+ return this._createTag({
+ notes: '',
+ name: text,
+ category: 'search',
+ redundant: false
+ });
+ }
+
+ _createPitches(details) {
+ this.preparePitchAccents();
+
+ const {dictionary, pitches} = details;
+
+ const node = this._templates.instantiate('term-pitch-accent-group');
+ node.dataset.dictionary = dictionary;
+ node.dataset.pitchesMulti = 'true';
+ node.dataset.pitchesCount = `${pitches.length}`;
+
+ const tag = this._createTag({notes: '', name: dictionary, category: 'pitch-accent-dictionary'});
+ node.querySelector('.term-pitch-accent-group-tag-list').appendChild(tag);
+
+ let hasTags = false;
+ for (const {tags} of pitches) {
+ if (tags.length > 0) {
+ hasTags = true;
+ break;
+ }
+ }
+
+ const n = node.querySelector('.term-pitch-accent-list');
+ n.dataset.hasTags = `${hasTags}`;
+ this._appendMultiple(n, this._createPitch.bind(this), pitches);
+
+ return node;
+ }
+
+ _createPitch(details) {
+ const jp = this._japaneseUtil;
+ const {reading, position, tags, exclusiveExpressions, exclusiveReadings} = details;
+ const morae = jp.getKanaMorae(reading);
+
+ const node = this._templates.instantiate('term-pitch-accent');
+
+ node.dataset.pitchAccentPosition = `${position}`;
+ node.dataset.tagCount = `${tags.length}`;
+
+ let n = node.querySelector('.term-pitch-accent-position');
+ this._setTextContent(n, `${position}`, '');
+
+ n = node.querySelector('.term-pitch-accent-tag-list');
+ this._appendMultiple(n, this._createTag.bind(this), tags);
+
+ n = node.querySelector('.term-pitch-accent-disambiguation-list');
+ this._createPitchAccentDisambiguations(n, exclusiveExpressions, exclusiveReadings);
+
+ n = node.querySelector('.term-pitch-accent-characters');
+ for (let i = 0, ii = morae.length; i < ii; ++i) {
+ const mora = morae[i];
+ const highPitch = jp.isMoraPitchHigh(i, position);
+ const highPitchNext = jp.isMoraPitchHigh(i + 1, position);
+
+ const n1 = this._templates.instantiate('term-pitch-accent-character');
+ const n2 = n1.querySelector('.term-pitch-accent-character-inner');
+
+ n1.dataset.position = `${i}`;
+ n1.dataset.pitch = highPitch ? 'high' : 'low';
+ n1.dataset.pitchNext = highPitchNext ? 'high' : 'low';
+ this._setTextContent(n2, mora, 'ja');
+
+ n.appendChild(n1);
+ }
+
+ if (morae.length > 0) {
+ this._populatePitchGraph(node.querySelector('.term-pitch-accent-graph'), position, morae);
+ }
+
+ return node;
+ }
+
+ _createPitchAccentDisambiguations(container, exclusiveExpressions, exclusiveReadings) {
+ const templateName = 'term-pitch-accent-disambiguation';
+ for (const exclusiveExpression of exclusiveExpressions) {
+ const node = this._templates.instantiate(templateName);
+ node.dataset.type = 'expression';
+ this._setTextContent(node, exclusiveExpression, 'ja');
+ container.appendChild(node);
+ }
+
+ for (const exclusiveReading of exclusiveReadings) {
+ const node = this._templates.instantiate(templateName);
+ node.dataset.type = 'reading';
+ this._setTextContent(node, exclusiveReading, 'ja');
+ container.appendChild(node);
+ }
+
+ container.dataset.count = `${exclusiveExpressions.length + exclusiveReadings.length}`;
+ container.dataset.expressionCount = `${exclusiveExpressions.length}`;
+ container.dataset.readingCount = `${exclusiveReadings.length}`;
+ }
+
+ _populatePitchGraph(svg, position, morae) {
+ const jp = this._japaneseUtil;
+ const svgns = svg.getAttribute('xmlns');
+ const ii = morae.length;
+ svg.setAttribute('viewBox', `0 0 ${50 * (ii + 1)} 100`);
+
+ const pathPoints = [];
+ for (let i = 0; i < ii; ++i) {
+ const highPitch = jp.isMoraPitchHigh(i, position);
+ const highPitchNext = jp.isMoraPitchHigh(i + 1, position);
+ const graphic = (highPitch && !highPitchNext ? '#term-pitch-accent-graph-dot-downstep' : '#term-pitch-accent-graph-dot');
+ const x = `${i * 50 + 25}`;
+ const y = highPitch ? '25' : '75';
+ const use = document.createElementNS(svgns, 'use');
+ use.setAttribute('href', graphic);
+ use.setAttribute('x', x);
+ use.setAttribute('y', y);
+ svg.appendChild(use);
+ pathPoints.push(`${x} ${y}`);
+ }
+
+ let path = svg.querySelector('.term-pitch-accent-graph-line');
+ path.setAttribute('d', `M${pathPoints.join(' L')}`);
+
+ pathPoints.splice(0, ii - 1);
+ {
+ const highPitch = jp.isMoraPitchHigh(ii, position);
+ const x = `${ii * 50 + 25}`;
+ const y = highPitch ? '25' : '75';
+ const use = document.createElementNS(svgns, 'use');
+ use.setAttribute('href', '#term-pitch-accent-graph-triangle');
+ use.setAttribute('x', x);
+ use.setAttribute('y', y);
+ svg.appendChild(use);
+ pathPoints.push(`${x} ${y}`);
+ }
+
+ path = svg.querySelector('.term-pitch-accent-graph-line-tail');
+ path.setAttribute('d', `M${pathPoints.join(' L')}`);
+ }
+
+ _createFrequencyGroup(details, kanji) {
+ const {dictionary, frequencyData} = details;
+ const node = this._templates.instantiate('frequency-group-item');
+
+ const tagList = node.querySelector('.frequency-tag-list');
+ const tag = this._createTag({notes: '', name: dictionary, category: 'frequency'});
+ tagList.appendChild(tag);
+
+ const frequencyListContainer = node.querySelector('.frequency-list');
+ const createItem = (kanji ? this._createKanjiFrequency.bind(this) : this._createTermFrequency.bind(this));
+ this._appendMultiple(frequencyListContainer, createItem, frequencyData, dictionary);
+
+ node.dataset.count = `${frequencyData.length}`;
+
+ return node;
+ }
+
+ _createTermFrequency(details, dictionary) {
+ const {expression, reading, frequencies} = details;
+ const node = this._templates.instantiate('term-frequency-item');
+
+ const frequency = frequencies.join(', ');
+
+ this._setTextContent(node.querySelector('.frequency-disambiguation-expression'), expression, 'ja');
+ this._setTextContent(node.querySelector('.frequency-disambiguation-reading'), (reading !== null ? reading : ''), 'ja');
+ this._setTextContent(node.querySelector('.frequency-value'), frequency, 'ja');
+
+ node.dataset.expression = expression;
+ node.dataset.reading = reading;
+ node.dataset.hasReading = `${reading !== null}`;
+ node.dataset.readingIsSame = `${reading === expression}`;
+ node.dataset.dictionary = dictionary;
+ node.dataset.frequency = `${frequency}`;
+
+ return node;
+ }
+
+ _createKanjiFrequency(details, dictionary) {
+ const {character, frequencies} = details;
+ const node = this._templates.instantiate('kanji-frequency-item');
+
+ const frequency = frequencies.join(', ');
+
+ this._setTextContent(node.querySelector('.frequency-value'), frequency, 'ja');
+
+ node.dataset.character = character;
+ node.dataset.dictionary = dictionary;
+ node.dataset.frequency = `${frequency}`;
+
+ return node;
+ }
+
+ _appendKanjiLinks(container, text) {
+ container.lang = 'ja';
+ const jp = this._japaneseUtil;
+ let part = '';
+ for (const c of text) {
+ if (jp.isCodePointKanji(c.codePointAt(0))) {
+ if (part.length > 0) {
+ container.appendChild(document.createTextNode(part));
+ part = '';
+ }
+
+ const link = this._createKanjiLink(c);
+ container.appendChild(link);
+ } else {
+ part += c;
+ }
+ }
+ if (part.length > 0) {
+ container.appendChild(document.createTextNode(part));
+ }
+ }
+
+ _appendMultiple(container, createItem, detailsArray, ...args) {
+ let count = 0;
+ const {ELEMENT_NODE} = Node;
+ if (Array.isArray(detailsArray)) {
+ for (const details of detailsArray) {
+ const item = createItem(details, ...args);
+ if (item === null) { continue; }
+ container.appendChild(item);
+ if (item.nodeType === ELEMENT_NODE) {
+ item.dataset.index = `${count}`;
+ }
+ ++count;
+ }
+ }
+
+ container.dataset.count = `${count}`;
+
+ return count;
+ }
+
+ _appendFurigana(container, segments, addText) {
+ for (const {text, furigana} of segments) {
+ if (furigana) {
+ const ruby = document.createElement('ruby');
+ const rt = document.createElement('rt');
+ addText(ruby, text);
+ ruby.appendChild(rt);
+ rt.appendChild(document.createTextNode(furigana));
+ container.appendChild(ruby);
+ } else {
+ addText(container, text);
+ }
+ }
+ }
+
+ _createDictionaryTag(dictionary) {
+ return {
+ name: dictionary,
+ category: 'dictionary',
+ notes: '',
+ order: 100,
+ score: 0,
+ dictionary,
+ redundant: false
+ };
+ }
+
+ _setTextContent(node, value, language) {
+ node.textContent = value;
+ if (typeof language === 'string') {
+ node.lang = language;
+ } else if (this._japaneseUtil.isStringPartiallyJapanese(value)) {
+ node.lang = 'ja';
+ }
+ }
+}
diff --git a/ext/js/display/display-history.js b/ext/js/display/display-history.js
new file mode 100644
index 00000000..a6335521
--- /dev/null
+++ b/ext/js/display/display-history.js
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2020-2021 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class DisplayHistory extends EventDispatcher {
+ constructor({clearable=true, useBrowserHistory=false}) {
+ super();
+ this._clearable = clearable;
+ this._useBrowserHistory = useBrowserHistory;
+ this._historyMap = new Map();
+
+ const historyState = history.state;
+ const {id, state} = isObject(historyState) ? historyState : {id: null, state: null};
+ this._current = this._createHistoryEntry(id, location.href, state, null, null);
+ }
+
+ get state() {
+ return this._current.state;
+ }
+
+ get content() {
+ return this._current.content;
+ }
+
+ get useBrowserHistory() {
+ return this._useBrowserHistory;
+ }
+
+ set useBrowserHistory(value) {
+ this._useBrowserHistory = value;
+ }
+
+ prepare() {
+ window.addEventListener('popstate', this._onPopState.bind(this), false);
+ }
+
+ hasNext() {
+ return this._current.next !== null;
+ }
+
+ hasPrevious() {
+ return this._current.previous !== null;
+ }
+
+ clear() {
+ if (!this._clearable) { return; }
+ this._clear();
+ }
+
+ back() {
+ return this._go(false);
+ }
+
+ forward() {
+ return this._go(true);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ _go(forward) {
+ const target = forward ? this._current.next : this._current.previous;
+ if (target === null) {
+ return false;
+ }
+
+ if (this._useBrowserHistory) {
+ if (forward) {
+ history.forward();
+ } else {
+ history.back();
+ }
+ } else {
+ this._current = target;
+ this._updateHistoryFromCurrent(true);
+ }
+
+ return true;
+ }
+
+ _triggerStateChanged(synthetic) {
+ this.trigger('stateChanged', {history: this, synthetic});
+ }
+
+ _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() {
+ let state = history.state;
+ let id = null;
+ if (isObject(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 = state;
+ this._current.content = null;
+ this._clear();
+ }
+
+ _createHistoryEntry(id, url, state, content, previous) {
+ if (typeof id !== 'string') { id = this._generateId(); }
+ const entry = {
+ id,
+ url,
+ next: null,
+ previous,
+ state,
+ content
+ };
+ this._historyMap.set(id, entry);
+ return entry;
+ }
+
+ _generateId() {
+ return generateId(16);
+ }
+
+ _clear() {
+ this._historyMap.clear();
+ this._historyMap.set(this._current.id, this._current);
+ this._current.next = null;
+ this._current.previous = null;
+ }
+}
diff --git a/ext/js/display/display-notification.js b/ext/js/display/display-notification.js
new file mode 100644
index 00000000..8b6325d0
--- /dev/null
+++ b/ext/js/display/display-notification.js
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2017-2021 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class DisplayNotification {
+ constructor(container, node) {
+ this._container = container;
+ this._node = node;
+ this._body = node.querySelector('.footer-notification-body');
+ this._closeButton = node.querySelector('.footer-notification-close-button');
+ this._eventListeners = new EventListenerCollection();
+ this._closeTimer = null;
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+ }
+ }
+
+ setContent(value) {
+ if (typeof value === 'string') {
+ this._body.textContent = value;
+ } else {
+ this._body.textContent = '';
+ this._body.appendChild(value);
+ }
+ }
+
+ isClosing() {
+ return this._closeTimer !== null;
+ }
+
+ 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;
+ }
+ }
+}
diff --git a/ext/js/display/display-profile-selection.js b/ext/js/display/display-profile-selection.js
new file mode 100644
index 00000000..0a44392e
--- /dev/null
+++ b/ext/js/display/display-profile-selection.js
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2020-2021 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/>.
+ */
+
+/* global
+ * PanelElement
+ * api
+ */
+
+class DisplayProfileSelection {
+ constructor(display) {
+ this._display = display;
+ this._profielList = document.querySelector('#profile-list');
+ this._profileButton = document.querySelector('#profile-button');
+ this._profilePanel = new PanelElement({
+ node: document.querySelector('#profile-panel'),
+ closingAnimationDuration: 375 // Milliseconds; includes buffer
+ });
+ this._profileListNeedsUpdate = false;
+ this._eventListeners = new EventListenerCollection();
+ this._source = generateId(16);
+ }
+
+ async prepare() {
+ yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this));
+ this._profileButton.addEventListener('click', this._onProfileButtonClick.bind(this), false);
+ this._profileListNeedsUpdate = true;
+ }
+
+ // Private
+
+ _onOptionsUpdated({source}) {
+ if (source === this._source) { return; }
+ this._profileListNeedsUpdate = true;
+ if (this._profilePanel.isVisible()) {
+ this._updateProfileList();
+ }
+ }
+
+ _onProfileButtonClick(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this._setProfilePanelVisible(!this._profilePanel.isVisible());
+ }
+
+ _setProfilePanelVisible(visible) {
+ this._profilePanel.setVisible(visible);
+ this._profileButton.classList.toggle('sidebar-button-highlight', visible);
+ document.documentElement.dataset.profilePanelVisible = `${visible}`;
+ if (visible && this._profileListNeedsUpdate) {
+ this._updateProfileList();
+ }
+ }
+
+ async _updateProfileList() {
+ this._profileListNeedsUpdate = false;
+ const options = await 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();
+ const radio = entry.querySelector('.profile-entry-is-default-radio');
+ radio.checked = (i === profileCurrent);
+ const nameNode = entry.querySelector('.profile-list-item-name');
+ nameNode.textContent = name;
+ fragment.appendChild(entry);
+ this._eventListeners.addEventListener(radio, 'change', this._onProfileRadioChange.bind(this, i), false);
+ }
+ this._profielList.textContent = '';
+ this._profielList.appendChild(fragment);
+ }
+
+ _onProfileRadioChange(index, e) {
+ if (e.currentTarget.checked) {
+ this._setProfileCurrent(index);
+ }
+ }
+
+ async _setProfileCurrent(index) {
+ await api.modifySettings([{
+ action: 'set',
+ path: 'profileCurrent',
+ value: index,
+ scope: 'global'
+ }], this._source);
+ this._setProfilePanelVisible(false);
+ }
+}
diff --git a/ext/js/display/display.js b/ext/js/display/display.js
new file mode 100644
index 00000000..ffadd055
--- /dev/null
+++ b/ext/js/display/display.js
@@ -0,0 +1,1886 @@
+/*
+ * Copyright (C) 2017-2021 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/>.
+ */
+
+/* global
+ * AnkiNoteBuilder
+ * DisplayAudio
+ * DisplayGenerator
+ * DisplayHistory
+ * DisplayNotification
+ * DocumentUtil
+ * FrameEndpoint
+ * Frontend
+ * HotkeyHelpController
+ * MediaLoader
+ * PopupFactory
+ * PopupMenu
+ * QueryParser
+ * TextScanner
+ * WindowScroll
+ * api
+ * dynamicLoader
+ */
+
+class Display extends EventDispatcher {
+ constructor(tabId, frameId, pageType, japaneseUtil, documentFocusController, hotkeyHandler) {
+ super();
+ this._tabId = tabId;
+ this._frameId = frameId;
+ this._pageType = pageType;
+ this._japaneseUtil = japaneseUtil;
+ this._documentFocusController = documentFocusController;
+ this._hotkeyHandler = hotkeyHandler;
+ this._container = document.querySelector('#definitions');
+ this._definitions = [];
+ this._definitionNodes = [];
+ this._optionsContext = {depth: 0, url: window.location.href};
+ this._options = null;
+ this._index = 0;
+ this._styleNode = null;
+ this._eventListeners = new EventListenerCollection();
+ this._setContentToken = null;
+ this._mediaLoader = new MediaLoader();
+ this._hotkeyHelpController = new HotkeyHelpController();
+ this._displayGenerator = new DisplayGenerator({
+ japaneseUtil,
+ mediaLoader: this._mediaLoader,
+ hotkeyHelpController: this._hotkeyHelpController
+ });
+ this._messageHandlers = new Map();
+ this._directMessageHandlers = new Map();
+ this._windowMessageHandlers = new Map();
+ this._history = new DisplayHistory({clearable: true, useBrowserHistory: false});
+ this._historyChangeIgnore = false;
+ this._historyHasChanged = false;
+ this._navigationHeader = document.querySelector('#navigation-header');
+ this._contentType = 'clear';
+ this._defaultTitle = document.title;
+ this._titleMaxLength = 1000;
+ this._query = '';
+ this._rawQuery = '';
+ this._fullQuery = '';
+ this._documentUtil = new DocumentUtil();
+ this._progressIndicator = document.querySelector('#progress-indicator');
+ this._progressIndicatorTimer = null;
+ this._progressIndicatorVisible = new DynamicProperty(false);
+ this._queryParserVisible = false;
+ this._queryParserVisibleOverride = null;
+ this._queryParserContainer = document.querySelector('#query-parser-container');
+ this._queryParser = new QueryParser({
+ getSearchContext: this._getSearchContext.bind(this),
+ documentUtil: this._documentUtil
+ });
+ this._ankiFieldTemplates = null;
+ this._ankiFieldTemplatesDefault = null;
+ this._ankiNoteBuilder = new AnkiNoteBuilder(true);
+ this._updateAdderButtonsPromise = Promise.resolve();
+ this._contentScrollElement = document.querySelector('#content-scroll');
+ this._contentScrollBodyElement = document.querySelector('#content-body');
+ this._windowScroll = new WindowScroll(this._contentScrollElement);
+ this._closeButton = document.querySelector('#close-button');
+ this._navigationPreviousButton = document.querySelector('#navigate-previous-button');
+ this._navigationNextButton = document.querySelector('#navigate-next-button');
+ this._frontend = null;
+ this._frontendSetupPromise = null;
+ this._depth = 0;
+ this._parentPopupId = null;
+ this._parentFrameId = null;
+ this._contentOriginTabId = tabId;
+ this._contentOriginFrameId = frameId;
+ this._childrenSupported = true;
+ this._frameEndpoint = (pageType === 'popup' ? new FrameEndpoint() : null);
+ this._browser = null;
+ this._copyTextarea = null;
+ this._definitionTextScanner = null;
+ this._frameResizeToken = null;
+ this._frameResizeHandle = document.querySelector('#frame-resizer-handle');
+ this._frameResizeStartSize = null;
+ this._frameResizeStartOffset = null;
+ this._frameResizeEventListeners = new EventListenerCollection();
+ this._tagNotification = null;
+ this._footerNotificationContainer = document.querySelector('#content-footer');
+ this._displayAudio = new DisplayAudio(this);
+ this._ankiNoteNotification = null;
+ this._ankiNoteNotificationEventListeners = null;
+ this._queryPostProcessor = null;
+
+ this._hotkeyHandler.registerActions([
+ ['close', () => { this._onHotkeyClose(); }],
+ ['nextEntry', () => { this._focusEntry(this._index + 1, true); }],
+ ['nextEntry3', () => { this._focusEntry(this._index + 3, true); }],
+ ['previousEntry', () => { this._focusEntry(this._index - 1, true); }],
+ ['previousEntry3', () => { this._focusEntry(this._index - 3, true); }],
+ ['lastEntry', () => { this._focusEntry(this._definitions.length - 1, true); }],
+ ['firstEntry', () => { this._focusEntry(0, true); }],
+ ['historyBackward', () => { this._sourceTermView(); }],
+ ['historyForward', () => { this._nextTermView(); }],
+ ['addNoteKanji', () => { this._tryAddAnkiNoteForSelectedDefinition('kanji'); }],
+ ['addNoteTermKanji', () => { this._tryAddAnkiNoteForSelectedDefinition('term-kanji'); }],
+ ['addNoteTermKana', () => { this._tryAddAnkiNoteForSelectedDefinition('term-kana'); }],
+ ['viewNote', () => { this._tryViewAnkiNoteForSelectedDefinition(); }],
+ ['playAudio', () => { this._playAudioCurrent(); }],
+ ['copyHostSelection', () => this._copyHostSelection()],
+ ['nextEntryDifferentDictionary', () => { this._focusEntryWithDifferentDictionary(1, true); }],
+ ['previousEntryDifferentDictionary', () => { this._focusEntryWithDifferentDictionary(-1, true); }]
+ ]);
+ this.registerDirectMessageHandlers([
+ ['setOptionsContext', {async: false, handler: this._onMessageSetOptionsContext.bind(this)}],
+ ['setContent', {async: false, handler: this._onMessageSetContent.bind(this)}],
+ ['clearAutoPlayTimer', {async: false, handler: this._onMessageClearAutoPlayTimer.bind(this)}],
+ ['setCustomCss', {async: false, handler: this._onMessageSetCustomCss.bind(this)}],
+ ['setContentScale', {async: false, handler: this._onMessageSetContentScale.bind(this)}],
+ ['configure', {async: true, handler: this._onMessageConfigure.bind(this)}]
+ ]);
+ this.registerWindowMessageHandlers([
+ ['extensionUnloaded', {async: false, handler: this._onMessageExtensionUnloaded.bind(this)}]
+ ]);
+ }
+
+ get displayGenerator() {
+ return this._displayGenerator;
+ }
+
+ get autoPlayAudioDelay() {
+ return this._displayAudio.autoPlayAudioDelay;
+ }
+
+ set autoPlayAudioDelay(value) {
+ this._displayAudio.autoPlayAudioDelay = value;
+ }
+
+ get queryParserVisible() {
+ return this._queryParserVisible;
+ }
+
+ set queryParserVisible(value) {
+ this._queryParserVisible = value;
+ this._updateQueryParser();
+ }
+
+ get japaneseUtil() {
+ return this._japaneseUtil;
+ }
+
+ get depth() {
+ return this._depth;
+ }
+
+ get hotkeyHandler() {
+ return this._hotkeyHandler;
+ }
+
+ get definitions() {
+ return this._definitions;
+ }
+
+ get definitionNodes() {
+ return this._definitionNodes;
+ }
+
+ get progressIndicatorVisible() {
+ return this._progressIndicatorVisible;
+ }
+
+ get tabId() {
+ return this._tabId;
+ }
+
+ get frameId() {
+ return this._frameId;
+ }
+
+ async prepare() {
+ // State setup
+ const {documentElement} = document;
+ const {browser} = await api.getEnvironmentInfo();
+ this._browser = browser;
+
+ // Prepare
+ await this._hotkeyHelpController.prepare();
+ await this._displayGenerator.prepare();
+ this._displayAudio.prepare();
+ this._queryParser.prepare();
+ this._history.prepare();
+
+ // Event setup
+ this._history.on('stateChanged', this._onStateChanged.bind(this));
+ this._queryParser.on('searched', this._onQueryParserSearch.bind(this));
+ this._progressIndicatorVisible.on('change', this._onProgressIndicatorVisibleChanged.bind(this));
+ yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this));
+ api.crossFrame.registerHandlers([
+ ['popupMessage', {async: 'dynamic', handler: this._onDirectMessage.bind(this)}]
+ ]);
+ window.addEventListener('message', this._onWindowMessage.bind(this), false);
+
+ if (this._pageType === 'popup' && documentElement !== null) {
+ documentElement.addEventListener('mouseup', this._onDocumentElementMouseUp.bind(this), false);
+ documentElement.addEventListener('click', this._onDocumentElementClick.bind(this), false);
+ documentElement.addEventListener('auxclick', this._onDocumentElementClick.bind(this), false);
+ }
+
+ document.addEventListener('wheel', this._onWheel.bind(this), {passive: false});
+ if (this._closeButton !== null) {
+ this._closeButton.addEventListener('click', this._onCloseButtonClick.bind(this), false);
+ }
+ if (this._navigationPreviousButton !== null) {
+ this._navigationPreviousButton.addEventListener('click', this._onSourceTermView.bind(this), false);
+ }
+ if (this._navigationNextButton !== null) {
+ this._navigationNextButton.addEventListener('click', this._onNextTermView.bind(this), false);
+ }
+
+ if (this._frameResizeHandle !== null) {
+ this._frameResizeHandle.addEventListener('mousedown', this._onFrameResizerMouseDown.bind(this), false);
+ }
+ }
+
+ getContentOrigin() {
+ return {
+ tabId: this._contentOriginTabId,
+ frameId: this._contentOriginFrameId
+ };
+ }
+
+ initializeState() {
+ this._onStateChanged();
+ if (this._frameEndpoint !== null) {
+ this._frameEndpoint.signal();
+ }
+ }
+
+ setHistorySettings({clearable, useBrowserHistory}) {
+ if (typeof clearable !== 'undefined') {
+ this._history.clearable = clearable;
+ }
+ if (typeof useBrowserHistory !== 'undefined') {
+ this._history.useBrowserHistory = useBrowserHistory;
+ }
+ }
+
+ onError(error) {
+ if (yomichan.isExtensionUnloaded) { return; }
+ yomichan.logError(error);
+ }
+
+ getOptions() {
+ return this._options;
+ }
+
+ getOptionsContext() {
+ return this._optionsContext;
+ }
+
+ async setOptionsContext(optionsContext) {
+ this._optionsContext = optionsContext;
+ await this.updateOptions();
+ }
+
+ async updateOptions() {
+ const options = await api.optionsGet(this.getOptionsContext());
+ const templates = await this._getAnkiFieldTemplates(options);
+ const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options;
+ this._options = options;
+ this._ankiFieldTemplates = templates;
+
+ this._updateHotkeys(options);
+ this._updateDocumentOptions(options);
+ this._updateTheme(options.general.popupTheme);
+ this.setCustomCss(options.general.customPopupCss);
+ this._displayAudio.updateOptions(options);
+ this._hotkeyHelpController.setOptions(options);
+ this._displayGenerator.updateHotkeys();
+ this._hotkeyHelpController.setupNode(document.documentElement);
+
+ this._queryParser.setOptions({
+ selectedParser: options.parsing.selectedParser,
+ termSpacing: options.parsing.termSpacing,
+ scanning: {
+ inputs: scanningOptions.inputs,
+ deepContentScan: scanningOptions.deepDomScan,
+ selectText: scanningOptions.selectText,
+ delay: scanningOptions.delay,
+ touchInputEnabled: scanningOptions.touchInputEnabled,
+ pointerEventsEnabled: scanningOptions.pointerEventsEnabled,
+ scanLength: scanningOptions.length,
+ layoutAwareScan: scanningOptions.layoutAwareScan,
+ preventMiddleMouse: scanningOptions.preventMiddleMouse.onSearchQuery,
+ sentenceParsingOptions
+ }
+ });
+
+ this._updateNestedFrontend(options);
+ this._updateDefinitionTextScanner(options);
+
+ this.trigger('optionsUpdated', {options});
+ }
+
+ clearAutoPlayTimer() {
+ this._displayAudio.clearAutoPlayTimer();
+ }
+
+ setContent(details) {
+ const {focus, history, params, state, content} = details;
+
+ if (focus) {
+ window.focus();
+ }
+
+ const urlSearchParams = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ urlSearchParams.append(key, value);
+ }
+ const url = `${location.protocol}//${location.host}${location.pathname}?${urlSearchParams.toString()}`;
+
+ if (history && this._historyHasChanged) {
+ this._updateHistoryState();
+ this._history.pushState(state, content, url);
+ } else {
+ this._history.clear();
+ this._history.replaceState(state, content, url);
+ }
+ }
+
+ setCustomCss(css) {
+ if (this._styleNode === null) {
+ if (css.length === 0) { return; }
+ this._styleNode = document.createElement('style');
+ }
+
+ this._styleNode.textContent = css;
+
+ const parent = document.head;
+ if (this._styleNode.parentNode !== parent) {
+ parent.appendChild(this._styleNode);
+ }
+ }
+
+ registerDirectMessageHandlers(handlers) {
+ for (const [name, handlerInfo] of handlers) {
+ this._directMessageHandlers.set(name, handlerInfo);
+ }
+ }
+
+ registerWindowMessageHandlers(handlers) {
+ for (const [name, handlerInfo] of handlers) {
+ this._windowMessageHandlers.set(name, handlerInfo);
+ }
+ }
+
+ authenticateMessageData(data) {
+ if (this._frameEndpoint === null) {
+ return data;
+ }
+ if (!this._frameEndpoint.authenticate(data)) {
+ throw new Error('Invalid authentication');
+ }
+ return data.data;
+ }
+
+ setQueryPostProcessor(func) {
+ this._queryPostProcessor = func;
+ }
+
+ close() {
+ switch (this._pageType) {
+ case 'popup':
+ this._invokeContentOrigin('closePopup');
+ break;
+ case 'search':
+ this._closeTab();
+ break;
+ }
+ }
+
+ blurElement(element) {
+ this._documentFocusController.blurElement(element);
+ }
+
+ searchLast() {
+ const type = this._contentType;
+ if (type === 'clear') { return; }
+ const query = this._rawQuery;
+ const state = (
+ this._historyHasState() ?
+ clone(this._history.state) :
+ {
+ focusEntry: 0,
+ optionsContext: this._optionsContext,
+ url: window.location.href,
+ sentence: {text: query, offset: 0},
+ documentTitle: document.title
+ }
+ );
+ const details = {
+ focus: false,
+ history: false,
+ params: this._createSearchParams(type, query, false),
+ state,
+ content: {
+ definitions: null,
+ contentOrigin: this.getContentOrigin()
+ }
+ };
+ this.setContent(details);
+ }
+
+ // Message handlers
+
+ _onDirectMessage(data) {
+ data = this.authenticateMessageData(data);
+ const {action, params} = data;
+ const handlerInfo = this._directMessageHandlers.get(action);
+ if (typeof handlerInfo === 'undefined') {
+ throw new Error(`Invalid action: ${action}`);
+ }
+
+ const {async, handler} = handlerInfo;
+ const result = handler(params);
+ return {async, result};
+ }
+
+ _onWindowMessage({data}) {
+ try {
+ data = this.authenticateMessageData(data);
+ } catch (e) {
+ return;
+ }
+
+ const {action, params} = data;
+ const messageHandler = this._windowMessageHandlers.get(action);
+ if (typeof messageHandler === 'undefined') { return; }
+
+ const callback = () => {}; // NOP
+ yomichan.invokeMessageHandler(messageHandler, params, callback);
+ }
+
+ _onMessageSetOptionsContext({optionsContext}) {
+ this.setOptionsContext(optionsContext);
+ this.searchLast();
+ }
+
+ _onMessageSetContent({details}) {
+ this.setContent(details);
+ }
+
+ _onMessageClearAutoPlayTimer() {
+ this.clearAutoPlayTimer();
+ }
+
+ _onMessageSetCustomCss({css}) {
+ this.setCustomCss(css);
+ }
+
+ _onMessageSetContentScale({scale}) {
+ this._setContentScale(scale);
+ }
+
+ async _onMessageConfigure({depth, parentPopupId, parentFrameId, childrenSupported, scale, optionsContext}) {
+ this._depth = depth;
+ this._parentPopupId = parentPopupId;
+ this._parentFrameId = parentFrameId;
+ this._childrenSupported = childrenSupported;
+ this._setContentScale(scale);
+ await this.setOptionsContext(optionsContext);
+ }
+
+ _onMessageExtensionUnloaded() {
+ if (yomichan.isExtensionUnloaded) { return; }
+ yomichan.triggerExtensionUnloaded();
+ }
+
+ // Private
+
+ async _onStateChanged() {
+ if (this._historyChangeIgnore) { return; }
+
+ const token = {}; // Unique identifier token
+ this._setContentToken = token;
+ try {
+ // Clear
+ this._closePopups();
+ this._closeAllPopupMenus();
+ this._eventListeners.removeAllEventListeners();
+ this._mediaLoader.unloadAll();
+ this._displayAudio.cleanupEntries();
+ this._hideTagNotification(false);
+ this._hideAnkiNoteErrors(false);
+ this._definitions = [];
+ this._definitionNodes = [];
+
+ // Prepare
+ const urlSearchParams = new URLSearchParams(location.search);
+ let type = urlSearchParams.get('type');
+ if (type === null) { type = 'terms'; }
+
+ const fullVisible = urlSearchParams.get('full-visible');
+ this._queryParserVisibleOverride = (fullVisible === null ? null : (fullVisible !== 'false'));
+ this._updateQueryParser();
+
+ let clear = true;
+ this._historyHasChanged = true;
+ this._contentType = type;
+ this._query = '';
+ this._rawQuery = '';
+ const eventArgs = {type, urlSearchParams, token};
+
+ // Set content
+ switch (type) {
+ case 'terms':
+ case 'kanji':
+ {
+ let query = urlSearchParams.get('query');
+ if (!query) { break; }
+
+ this._query = query;
+ clear = false;
+ const isTerms = (type === 'terms');
+ query = this._postProcessQuery(query);
+ this._rawQuery = query;
+ let queryFull = urlSearchParams.get('full');
+ queryFull = (queryFull !== null ? this._postProcessQuery(queryFull) : query);
+ const wildcardsEnabled = (urlSearchParams.get('wildcards') !== 'off');
+ const lookup = (urlSearchParams.get('lookup') !== 'false');
+ await this._setContentTermsOrKanji(token, isTerms, query, queryFull, lookup, wildcardsEnabled, eventArgs);
+ }
+ break;
+ case 'unloaded':
+ {
+ clear = false;
+ const {content} = this._history;
+ eventArgs.content = content;
+ this.trigger('contentUpdating', eventArgs);
+ this._setContentExtensionUnloaded();
+ }
+ break;
+ }
+
+ // Clear
+ if (clear) {
+ type = 'clear';
+ this._contentType = type;
+ const {content} = this._history;
+ eventArgs.type = type;
+ eventArgs.content = content;
+ this.trigger('contentUpdating', eventArgs);
+ this._clearContent();
+ }
+
+ const stale = (this._setContentToken !== token);
+ eventArgs.stale = stale;
+ this.trigger('contentUpdated', eventArgs);
+ } catch (e) {
+ this.onError(e);
+ }
+ }
+
+ _onQueryParserSearch({type, definitions, sentence, inputInfo: {eventType}, textSource, optionsContext}) {
+ const query = textSource.text();
+ const historyState = this._history.state;
+ const history = (
+ eventType === 'click' ||
+ !isObject(historyState) ||
+ historyState.cause !== 'queryParser'
+ );
+ const details = {
+ focus: false,
+ history,
+ params: this._createSearchParams(type, query, false),
+ state: {
+ sentence,
+ optionsContext,
+ cause: 'queryParser'
+ },
+ content: {
+ definitions,
+ contentOrigin: this.getContentOrigin()
+ }
+ };
+ this.setContent(details);
+ }
+
+ _onExtensionUnloaded() {
+ const type = 'unloaded';
+ if (this._contentType === type) { return; }
+ const details = {
+ focus: false,
+ history: false,
+ params: {type},
+ state: {},
+ content: {
+ contentOrigin: {
+ tabId: this._tabId,
+ frameId: this._frameId
+ }
+ }
+ };
+ this.setContent(details);
+ }
+
+ _onCloseButtonClick(e) {
+ e.preventDefault();
+ this.close();
+ }
+
+ _onSourceTermView(e) {
+ e.preventDefault();
+ this._sourceTermView();
+ }
+
+ _onNextTermView(e) {
+ e.preventDefault();
+ this._nextTermView();
+ }
+
+ _onProgressIndicatorVisibleChanged({value}) {
+ if (this._progressIndicatorTimer !== null) {
+ clearTimeout(this._progressIndicatorTimer);
+ this._progressIndicatorTimer = null;
+ }
+
+ if (value) {
+ this._progressIndicator.hidden = false;
+ getComputedStyle(this._progressIndicator).getPropertyValue('display'); // Force update of CSS display property, allowing animation
+ this._progressIndicator.dataset.active = 'true';
+ } else {
+ this._progressIndicator.dataset.active = 'false';
+ this._progressIndicatorTimer = setTimeout(() => {
+ this._progressIndicator.hidden = true;
+ this._progressIndicatorTimer = null;
+ }, 250);
+ }
+ }
+
+ async _onKanjiLookup(e) {
+ try {
+ e.preventDefault();
+ if (!this._historyHasState()) { return; }
+
+ let {state: {sentence, url, documentTitle}} = this._history;
+ if (typeof url !== 'string') { url = window.location.href; }
+ if (typeof documentTitle !== 'string') { documentTitle = document.title; }
+ const optionsContext = this.getOptionsContext();
+ const query = e.currentTarget.textContent;
+ const definitions = await api.kanjiFind(query, optionsContext);
+ const details = {
+ focus: false,
+ history: true,
+ params: this._createSearchParams('kanji', query, false),
+ state: {
+ focusEntry: 0,
+ optionsContext,
+ url,
+ sentence,
+ documentTitle
+ },
+ content: {
+ definitions,
+ contentOrigin: this.getContentOrigin()
+ }
+ };
+ this.setContent(details);
+ } catch (error) {
+ this.onError(error);
+ }
+ }
+
+ _onNoteAdd(e) {
+ e.preventDefault();
+ const link = e.currentTarget;
+ const index = this._getClosestDefinitionIndex(link);
+ this._addAnkiNote(index, link.dataset.mode);
+ }
+
+ _onNoteView(e) {
+ e.preventDefault();
+ const link = e.currentTarget;
+ api.noteView(link.dataset.noteId);
+ }
+
+ _onWheel(e) {
+ if (e.altKey) {
+ if (e.deltaY !== 0) {
+ this._focusEntry(this._index + (e.deltaY > 0 ? 1 : -1), true);
+ e.preventDefault();
+ }
+ } else if (e.shiftKey) {
+ this._onHistoryWheel(e);
+ }
+ }
+
+ _onHistoryWheel(e) {
+ if (e.altKey) { return; }
+ const delta = -e.deltaX || e.deltaY;
+ if (delta > 0) {
+ this._sourceTermView();
+ e.preventDefault();
+ e.stopPropagation();
+ } else if (delta < 0) {
+ this._nextTermView();
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ _onDebugLogClick(e) {
+ const link = e.currentTarget;
+ const index = this._getClosestDefinitionIndex(link);
+ if (index < 0 || index >= this._definitions.length) { return; }
+ const definition = this._definitions[index];
+ console.log(definition);
+ }
+
+ _onDocumentElementMouseUp(e) {
+ switch (e.button) {
+ case 3: // Back
+ if (this._history.hasPrevious()) {
+ e.preventDefault();
+ }
+ break;
+ case 4: // Forward
+ if (this._history.hasNext()) {
+ e.preventDefault();
+ }
+ break;
+ }
+ }
+
+ _onDocumentElementClick(e) {
+ switch (e.button) {
+ case 3: // Back
+ if (this._history.hasPrevious()) {
+ e.preventDefault();
+ this._history.back();
+ }
+ break;
+ case 4: // Forward
+ if (this._history.hasNext()) {
+ e.preventDefault();
+ this._history.forward();
+ }
+ break;
+ }
+ }
+
+ _onEntryClick(e) {
+ if (e.button !== 0) { return; }
+ const node = e.currentTarget;
+ const index = parseInt(node.dataset.index, 10);
+ if (!Number.isFinite(index)) { return; }
+ this._entrySetCurrent(index);
+ }
+
+ _onTagClick(e) {
+ this._showTagNotification(e.currentTarget);
+ }
+
+ _showTagNotification(tagNode) {
+ if (this._tagNotification === null) {
+ const node = this._displayGenerator.createEmptyFooterNotification();
+ node.classList.add('click-scannable');
+ this._tagNotification = new DisplayNotification(this._footerNotificationContainer, node);
+ }
+
+ const content = this._displayGenerator.createTagFooterNotificationDetails(tagNode);
+ this._tagNotification.setContent(content);
+ this._tagNotification.open();
+ }
+
+ _hideTagNotification(animate) {
+ if (this._tagNotification === null) { return; }
+ this._tagNotification.close(animate);
+ }
+
+ _updateDocumentOptions(options) {
+ const data = document.documentElement.dataset;
+ data.ankiEnabled = `${options.anki.enable}`;
+ data.glossaryLayoutMode = `${options.general.glossaryLayoutMode}`;
+ data.compactTags = `${options.general.compactTags}`;
+ data.enableSearchTags = `${options.scanning.enableSearchTags}`;
+ data.showPitchAccentDownstepNotation = `${options.general.showPitchAccentDownstepNotation}`;
+ data.showPitchAccentPositionNotation = `${options.general.showPitchAccentPositionNotation}`;
+ data.showPitchAccentGraph = `${options.general.showPitchAccentGraph}`;
+ data.debug = `${options.general.debugInfo}`;
+ data.popupDisplayMode = `${options.general.popupDisplayMode}`;
+ data.popupCurrentIndicatorMode = `${options.general.popupCurrentIndicatorMode}`;
+ data.popupActionBarVisibility = `${options.general.popupActionBarVisibility}`;
+ data.popupActionBarLocation = `${options.general.popupActionBarLocation}`;
+ }
+
+ _updateTheme(themeName) {
+ document.documentElement.dataset.theme = themeName;
+ }
+
+ async _findDefinitions(isTerms, source, wildcardsEnabled, optionsContext) {
+ if (isTerms) {
+ const findDetails = {};
+ if (wildcardsEnabled) {
+ const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(source);
+ if (match !== null) {
+ if (match[1]) {
+ findDetails.wildcard = 'prefix';
+ } else if (match[3]) {
+ findDetails.wildcard = 'suffix';
+ }
+ source = match[2];
+ }
+ }
+
+ const {definitions} = await api.termsFind(source, findDetails, optionsContext);
+ return definitions;
+ } else {
+ const definitions = await api.kanjiFind(source, optionsContext);
+ return definitions;
+ }
+ }
+
+ async _setContentTermsOrKanji(token, isTerms, query, queryFull, lookup, wildcardsEnabled, eventArgs) {
+ let {state, content} = this._history;
+ let changeHistory = false;
+ if (!isObject(content)) {
+ content = {};
+ changeHistory = true;
+ }
+ if (!isObject(state)) {
+ state = {};
+ changeHistory = true;
+ }
+
+ let {
+ focusEntry=null,
+ scrollX=null,
+ scrollY=null,
+ optionsContext=null
+ } = state;
+ if (typeof focusEntry !== 'number') { focusEntry = 0; }
+ if (!(typeof optionsContext === 'object' && optionsContext !== null)) {
+ optionsContext = this.getOptionsContext();
+ state.optionsContext = optionsContext;
+ changeHistory = true;
+ }
+
+ this._setFullQuery(queryFull);
+ this._setTitleText(query);
+
+ let {definitions} = content;
+ if (!Array.isArray(definitions)) {
+ definitions = lookup ? await this._findDefinitions(isTerms, query, wildcardsEnabled, optionsContext) : [];
+ if (this._setContentToken !== token) { return; }
+ content.definitions = definitions;
+ changeHistory = true;
+ }
+
+ let contentOriginValid = false;
+ const {contentOrigin} = content;
+ if (typeof contentOrigin === 'object' && contentOrigin !== null) {
+ const {tabId, frameId} = contentOrigin;
+ if (typeof tabId === 'number' && typeof frameId === 'number') {
+ this._contentOriginTabId = tabId;
+ this._contentOriginFrameId = frameId;
+ if (this._pageType === 'popup') {
+ this._hotkeyHandler.forwardFrameId = (tabId === this._tabId ? frameId : null);
+ }
+ contentOriginValid = true;
+ }
+ }
+ if (!contentOriginValid) {
+ content.contentOrigin = this.getContentOrigin();
+ changeHistory = true;
+ }
+
+ await this._setOptionsContextIfDifferent(optionsContext);
+ if (this._setContentToken !== token) { return; }
+
+ if (this._options === null) {
+ await this.updateOptions();
+ if (this._setContentToken !== token) { return; }
+ }
+
+ if (changeHistory) {
+ this._replaceHistoryStateNoNavigate(state, content);
+ }
+
+ eventArgs.source = query;
+ eventArgs.content = content;
+ this.trigger('contentUpdating', eventArgs);
+
+ this._definitions = definitions;
+
+ this._updateNavigation(this._history.hasPrevious(), this._history.hasNext());
+ this._setNoContentVisible(definitions.length === 0 && lookup);
+
+ const container = this._container;
+ container.textContent = '';
+
+ for (let i = 0, ii = definitions.length; i < ii; ++i) {
+ if (i > 0) {
+ await promiseTimeout(1);
+ if (this._setContentToken !== token) { return; }
+ }
+
+ const definition = definitions[i];
+ const entry = (
+ isTerms ?
+ this._displayGenerator.createTermEntry(definition) :
+ this._displayGenerator.createKanjiEntry(definition)
+ );
+ entry.dataset.index = `${i}`;
+ this._definitionNodes.push(entry);
+ this._addEntryEventListeners(entry);
+ this._displayAudio.setupEntry(entry, i);
+ container.appendChild(entry);
+ if (focusEntry === i) {
+ this._focusEntry(i, false);
+ }
+ }
+
+ if (typeof scrollX === 'number' || typeof scrollY === 'number') {
+ let {x, y} = this._windowScroll;
+ if (typeof scrollX === 'number') { x = scrollX; }
+ if (typeof scrollY === 'number') { y = scrollY; }
+ this._windowScroll.stop();
+ this._windowScroll.to(x, y);
+ }
+
+ this._displayAudio.setupEntriesComplete();
+
+ this._updateAdderButtons(token, isTerms, definitions);
+ }
+
+ _setContentExtensionUnloaded() {
+ const errorExtensionUnloaded = document.querySelector('#error-extension-unloaded');
+
+ if (this._container !== null) {
+ this._container.hidden = true;
+ }
+
+ if (errorExtensionUnloaded !== null) {
+ errorExtensionUnloaded.hidden = false;
+ }
+
+ this._updateNavigation(false, false);
+ this._setNoContentVisible(false);
+ this._setTitleText('');
+ this._setFullQuery('');
+ }
+
+ _clearContent() {
+ this._container.textContent = '';
+ this._setTitleText('');
+ this._setFullQuery('');
+ }
+
+ _setNoContentVisible(visible) {
+ const noResults = document.querySelector('#no-results');
+
+ if (noResults !== null) {
+ noResults.hidden = !visible;
+ }
+ }
+
+ _setFullQuery(text) {
+ this._fullQuery = text;
+ this._updateQueryParser();
+ }
+
+ _updateQueryParser() {
+ const text = this._fullQuery;
+ const visible = this._isQueryParserVisible();
+ this._queryParserContainer.hidden = !visible || text.length === 0;
+ if (visible && this._queryParser.text !== text) {
+ this._setQueryParserText(text);
+ }
+ }
+
+ async _setQueryParserText(text) {
+ const overrideToken = this._progressIndicatorVisible.setOverride(true);
+ try {
+ await this._queryParser.setText(text);
+ } finally {
+ this._progressIndicatorVisible.clearOverride(overrideToken);
+ }
+ }
+
+ _setTitleText(text) {
+ let title = this._defaultTitle;
+ if (text.length > 0) {
+ // Chrome limits title to 1024 characters
+ const ellipsis = '...';
+ const separator = ' - ';
+ const maxLength = this._titleMaxLength - title.length - separator.length;
+ if (text.length > maxLength) {
+ text = `${text.substring(0, Math.max(0, maxLength - ellipsis.length))}${ellipsis}`;
+ }
+
+ title = `${text}${separator}${title}`;
+ }
+ document.title = title;
+ }
+
+ _updateNavigation(previous, next) {
+ const {documentElement} = document;
+ if (documentElement !== null) {
+ documentElement.dataset.hasNavigationPrevious = `${previous}`;
+ documentElement.dataset.hasNavigationNext = `${next}`;
+ }
+ if (this._navigationPreviousButton !== null) {
+ this._navigationPreviousButton.disabled = !previous;
+ }
+ if (this._navigationNextButton !== null) {
+ this._navigationNextButton.disabled = !next;
+ }
+ }
+
+ async _updateAdderButtons(token, isTerms, definitions) {
+ await this._updateAdderButtonsPromise;
+ if (this._setContentToken !== token) { return; }
+
+ const {promise, resolve} = deferPromise();
+ try {
+ this._updateAdderButtonsPromise = promise;
+
+ const modes = isTerms ? ['term-kanji', 'term-kana'] : ['kanji'];
+ let states;
+ try {
+ if (this._options.anki.checkForDuplicates) {
+ const noteContext = this._getNoteContext();
+ states = await this._areDefinitionsAddable(definitions, modes, noteContext);
+ } else {
+ if (!await api.isAnkiConnected()) {
+ throw new Error('Anki not connected');
+ }
+ states = this._areDefinitionsAddableForcedValue(definitions, modes, true);
+ }
+ } catch (e) {
+ return;
+ }
+
+ if (this._setContentToken !== token) { return; }
+
+ this._updateAdderButtons2(states, modes);
+ } finally {
+ resolve();
+ }
+ }
+
+ _updateAdderButtons2(states, modes) {
+ for (let i = 0, ii = states.length; i < ii; ++i) {
+ const infos = states[i];
+ let noteId = null;
+ for (let j = 0, jj = infos.length; j < jj; ++j) {
+ const {canAdd, noteIds} = infos[j];
+ const mode = modes[j];
+ const button = this._adderButtonFind(i, mode);
+ if (button === null) {
+ continue;
+ }
+
+ if (Array.isArray(noteIds) && noteIds.length > 0) {
+ noteId = noteIds[0];
+ }
+ button.disabled = !canAdd;
+ button.hidden = false;
+ }
+ if (noteId !== null) {
+ this._viewerButtonShow(i, noteId);
+ }
+ }
+ }
+
+ _entrySetCurrent(index) {
+ const entryPre = this._getEntry(this._index);
+ if (entryPre !== null) {
+ entryPre.classList.remove('entry-current');
+ }
+
+ const entry = this._getEntry(index);
+ if (entry !== null) {
+ entry.classList.add('entry-current');
+ }
+
+ this._index = index;
+
+ return entry;
+ }
+
+ _focusEntry(index, smooth) {
+ index = Math.max(Math.min(index, this._definitions.length - 1), 0);
+
+ const entry = this._entrySetCurrent(index);
+ let target = index === 0 || entry === null ? 0 : this._getElementTop(entry);
+
+ if (this._navigationHeader !== null) {
+ target -= this._navigationHeader.getBoundingClientRect().height;
+ }
+
+ this._windowScroll.stop();
+ if (smooth) {
+ this._windowScroll.animate(this._windowScroll.x, target, 200);
+ } else {
+ this._windowScroll.toY(target);
+ }
+ }
+
+ _focusEntryWithDifferentDictionary(offset, smooth) {
+ const offsetSign = Math.sign(offset);
+ if (offsetSign === 0) { return false; }
+
+ let index = this._index;
+ const definitionCount = this._definitions.length;
+ if (index < 0 || index >= definitionCount) { return false; }
+
+ const {dictionary} = this._definitions[index];
+ for (let indexNext = index + offsetSign; indexNext >= 0 && indexNext < definitionCount; indexNext += offsetSign) {
+ const {dictionaryNames} = this._definitions[indexNext];
+ if (dictionaryNames.length > 1 || !dictionaryNames.includes(dictionary)) {
+ offset -= offsetSign;
+ if (Math.sign(offsetSign) !== offset) {
+ index = indexNext;
+ break;
+ }
+ }
+ }
+
+ if (index === this._index) { return false; }
+
+ this._focusEntry(index, smooth);
+ return true;
+ }
+
+ _sourceTermView() {
+ this._relativeTermView(false);
+ }
+
+ _nextTermView() {
+ this._relativeTermView(true);
+ }
+
+ _relativeTermView(next) {
+ if (next) {
+ return this._history.hasNext() && this._history.forward();
+ } else {
+ return this._history.hasPrevious() && this._history.back();
+ }
+ }
+
+ _tryAddAnkiNoteForSelectedDefinition(mode) {
+ this._addAnkiNote(this._index, mode);
+ }
+
+ _tryViewAnkiNoteForSelectedDefinition() {
+ const button = this._viewerButtonFind(this._index);
+ if (button !== null && !button.disabled) {
+ api.noteView(button.dataset.noteId);
+ }
+ }
+
+ async _addAnkiNote(definitionIndex, mode) {
+ if (definitionIndex < 0 || definitionIndex >= this._definitions.length) { return; }
+ const definition = this._definitions[definitionIndex];
+
+ const button = this._adderButtonFind(definitionIndex, mode);
+ if (button === null || button.disabled) { return; }
+
+ this._hideAnkiNoteErrors(true);
+
+ const errors = [];
+ const overrideToken = this._progressIndicatorVisible.setOverride(true);
+ try {
+ const {anki: {suspendNewCards}} = this._options;
+ const noteContext = this._getNoteContext();
+ const note = await this._createNote(definition, mode, noteContext, true, errors);
+
+ let noteId = null;
+ let addNoteOkay = false;
+ try {
+ noteId = await api.addAnkiNote(note);
+ addNoteOkay = true;
+ } catch (e) {
+ errors.length = 0;
+ errors.push(e);
+ }
+
+ if (addNoteOkay) {
+ if (noteId === null) {
+ errors.push(new Error('Note could not be added'));
+ } else {
+ if (suspendNewCards) {
+ try {
+ await api.suspendAnkiCardsForNote(noteId);
+ } catch (e) {
+ errors.push(e);
+ }
+ }
+ button.disabled = true;
+ this._viewerButtonShow(definitionIndex, noteId);
+ }
+ }
+ } catch (e) {
+ errors.push(e);
+ } finally {
+ this._progressIndicatorVisible.clearOverride(overrideToken);
+ }
+
+ if (errors.length > 0) {
+ this._showAnkiNoteErrors(errors);
+ } else {
+ this._hideAnkiNoteErrors(true);
+ }
+ }
+
+ _showAnkiNoteErrors(errors) {
+ if (this._ankiNoteNotificationEventListeners !== null) {
+ this._ankiNoteNotificationEventListeners.removeAllEventListeners();
+ }
+
+ if (this._ankiNoteNotification === null) {
+ const node = this._displayGenerator.createEmptyFooterNotification();
+ this._ankiNoteNotification = new DisplayNotification(this._footerNotificationContainer, node);
+ this._ankiNoteNotificationEventListeners = new EventListenerCollection();
+ }
+
+ const content = this._displayGenerator.createAnkiNoteErrorsNotificationContent(errors);
+ for (const node of content.querySelectorAll('.anki-note-error-log-link')) {
+ this._ankiNoteNotificationEventListeners.addEventListener(node, 'click', () => {
+ console.log({ankiNoteErrors: errors});
+ }, false);
+ }
+
+ this._ankiNoteNotification.setContent(content);
+ this._ankiNoteNotification.open();
+ }
+
+ _hideAnkiNoteErrors(animate) {
+ if (this._ankiNoteNotification === null) { return; }
+ this._ankiNoteNotification.close(animate);
+ this._ankiNoteNotificationEventListeners.removeAllEventListeners();
+ }
+
+ async _playAudioCurrent() {
+ return await this._displayAudio.playAudio(this._index, 0);
+ }
+
+ _getEntry(index) {
+ const entries = this._definitionNodes;
+ return index >= 0 && index < entries.length ? entries[index] : null;
+ }
+
+ _getValidSentenceData(sentence) {
+ let {text, offset} = (isObject(sentence) ? sentence : {});
+ if (typeof text !== 'string') { text = ''; }
+ if (typeof offset !== 'number') { offset = 0; }
+ return {text, offset};
+ }
+
+ _getClosestDefinitionIndex(element) {
+ return this._getClosestIndex(element, '.entry');
+ }
+
+ _getClosestIndex(element, selector) {
+ const node = element.closest(selector);
+ if (node === null) { return -1; }
+ const index = parseInt(node.dataset.index, 10);
+ return Number.isFinite(index) ? index : -1;
+ }
+
+ _adderButtonFind(index, mode) {
+ const entry = this._getEntry(index);
+ return entry !== null ? entry.querySelector(`.action-add-note[data-mode="${mode}"]`) : null;
+ }
+
+ _viewerButtonFind(index) {
+ const entry = this._getEntry(index);
+ return entry !== null ? entry.querySelector('.action-view-note') : null;
+ }
+
+ _viewerButtonShow(index, noteId) {
+ const viewerButton = this._viewerButtonFind(index);
+ if (viewerButton === null) {
+ return;
+ }
+ viewerButton.disabled = false;
+ viewerButton.hidden = false;
+ viewerButton.dataset.noteId = noteId;
+ }
+
+ _getElementTop(element) {
+ const elementRect = element.getBoundingClientRect();
+ const documentRect = this._contentScrollBodyElement.getBoundingClientRect();
+ return elementRect.top - documentRect.top;
+ }
+
+ _getNoteContext() {
+ const {state} = this._history;
+ let {documentTitle, url, sentence} = (isObject(state) ? state : {});
+ if (typeof documentTitle !== 'string') {
+ documentTitle = '';
+ }
+ if (typeof url !== 'string') {
+ url = window.location.href;
+ }
+ sentence = this._getValidSentenceData(sentence);
+ return {
+ url,
+ sentence,
+ documentTitle
+ };
+ }
+
+ _historyHasState() {
+ return isObject(this._history.state);
+ }
+
+ _updateHistoryState() {
+ const {state, content} = this._history;
+ if (!isObject(state)) { return; }
+
+ state.focusEntry = this._index;
+ state.scrollX = this._windowScroll.x;
+ state.scrollY = this._windowScroll.y;
+ this._replaceHistoryStateNoNavigate(state, content);
+ }
+
+ _replaceHistoryStateNoNavigate(state, content) {
+ const historyChangeIgnorePre = this._historyChangeIgnore;
+ try {
+ this._historyChangeIgnore = true;
+ this._history.replaceState(state, content);
+ } finally {
+ this._historyChangeIgnore = historyChangeIgnorePre;
+ }
+ }
+
+ _createSearchParams(type, query, wildcards) {
+ const params = {};
+ if (query.length < this._fullQuery.length) {
+ params.full = this._fullQuery;
+ }
+ params.query = query;
+ if (typeof type === 'string') {
+ params.type = type;
+ }
+ if (!wildcards) {
+ params.wildcards = 'off';
+ }
+ if (this._queryParserVisibleOverride !== null) {
+ params['full-visible'] = `${this._queryParserVisibleOverride}`;
+ }
+ return params;
+ }
+
+ _isQueryParserVisible() {
+ return (
+ this._queryParserVisibleOverride !== null ?
+ this._queryParserVisibleOverride :
+ this._queryParserVisible
+ );
+ }
+
+ _closePopups() {
+ yomichan.trigger('closePopups');
+ }
+
+ async _getAnkiFieldTemplates(options) {
+ let templates = options.anki.fieldTemplates;
+ if (typeof templates === 'string') { return templates; }
+
+ templates = this._ankiFieldTemplatesDefault;
+ if (typeof templates === 'string') { return templates; }
+
+ templates = await api.getDefaultAnkiFieldTemplates();
+ this._ankiFieldTemplatesDefault = templates;
+ return templates;
+ }
+
+ async _areDefinitionsAddable(definitions, modes, context) {
+ const modeCount = modes.length;
+ const notePromises = [];
+ for (const definition of definitions) {
+ for (const mode of modes) {
+ const notePromise = this._createNote(definition, mode, context, false, null);
+ notePromises.push(notePromise);
+ }
+ }
+ const notes = await Promise.all(notePromises);
+
+ const infos = await api.getAnkiNoteInfo(notes);
+ const results = [];
+ for (let i = 0, ii = infos.length; i < ii; i += modeCount) {
+ results.push(infos.slice(i, i + modeCount));
+ }
+ return results;
+ }
+
+ _areDefinitionsAddableForcedValue(definitions, modes, canAdd) {
+ const results = [];
+ const definitionCount = definitions.length;
+ const modeCount = modes.length;
+ for (let i = 0; i < definitionCount; ++i) {
+ const modeArray = [];
+ for (let j = 0; j < modeCount; ++j) {
+ modeArray.push({canAdd, noteIds: null});
+ }
+ results.push(modeArray);
+ }
+ return results;
+ }
+
+ async _createNote(definition, mode, context, injectMedia, errors) {
+ const options = this._options;
+ const templates = this._ankiFieldTemplates;
+ const {
+ general: {resultOutputMode, glossaryLayoutMode, compactTags},
+ anki: ankiOptions
+ } = options;
+ const {tags, checkForDuplicates, duplicateScope} = ankiOptions;
+ const modeOptions = (mode === 'kanji') ? ankiOptions.kanji : ankiOptions.terms;
+ const {deck: deckName, model: modelName} = modeOptions;
+ const fields = Object.entries(modeOptions.fields);
+
+ let injectedMedia = null;
+ if (injectMedia) {
+ let errors2;
+ ({result: injectedMedia, errors: errors2} = await this._injectAnkiNoteMedia(definition, mode, options, fields));
+ if (Array.isArray(errors)) {
+ for (const error of errors2) {
+ errors.push(deserializeError(error));
+ }
+ }
+ }
+
+ return await this._ankiNoteBuilder.createNote({
+ definition,
+ mode,
+ context,
+ templates,
+ deckName,
+ modelName,
+ fields,
+ tags,
+ checkForDuplicates,
+ duplicateScope,
+ resultOutputMode,
+ glossaryLayoutMode,
+ compactTags,
+ injectedMedia,
+ errors
+ });
+ }
+
+ async _injectAnkiNoteMedia(definition, mode, options, fields) {
+ const {
+ anki: {screenshot: {format, quality}},
+ audio: {sources, customSourceUrl, customSourceType}
+ } = options;
+
+ const timestamp = Date.now();
+ const definitionDetails = this._getDefinitionDetailsForNote(definition);
+ const audioDetails = (mode !== 'kanji' && this._ankiNoteBuilder.containsMarker(fields, 'audio') ? {sources, customSourceUrl, customSourceType} : null);
+ const screenshotDetails = (this._ankiNoteBuilder.containsMarker(fields, 'screenshot') ? {tabId: this._contentOriginTabId, frameId: this._contentOriginFrameId, format, quality} : null);
+ const clipboardDetails = {
+ image: this._ankiNoteBuilder.containsMarker(fields, 'clipboard-image'),
+ text: this._ankiNoteBuilder.containsMarker(fields, 'clipboard-text')
+ };
+ return await api.injectAnkiNoteMedia(
+ timestamp,
+ definitionDetails,
+ audioDetails,
+ screenshotDetails,
+ clipboardDetails
+ );
+ }
+
+ _getDefinitionDetailsForNote(definition) {
+ const {type} = definition;
+ if (type === 'kanji') {
+ const {character} = definition;
+ return {type, character};
+ }
+
+ const termDetailsList = definition.expressions;
+ let bestIndex = -1;
+ for (let i = 0, ii = termDetailsList.length; i < ii; ++i) {
+ const {sourceTerm, expression, reading} = termDetailsList[i];
+ if (expression === sourceTerm) {
+ bestIndex = i;
+ break;
+ } else if (reading === sourceTerm && bestIndex < 0) {
+ bestIndex = i;
+ }
+ }
+ const {expression, reading} = termDetailsList[Math.max(0, bestIndex)];
+ return {type, expression, reading};
+ }
+
+ async _setOptionsContextIfDifferent(optionsContext) {
+ if (deepEqual(this._optionsContext, optionsContext)) { return; }
+ await this.setOptionsContext(optionsContext);
+ }
+
+ _setContentScale(scale) {
+ const body = document.body;
+ if (body === null) { return; }
+ body.style.fontSize = `${scale}em`;
+ }
+
+ async _updateNestedFrontend(options) {
+ const isSearchPage = (this._pageType === 'search');
+ const isEnabled = this._childrenSupported && (
+ (isSearchPage) ?
+ (options.scanning.enableOnSearchPage) :
+ (this._depth < options.scanning.popupNestingMaxDepth)
+ );
+
+ if (this._frontend === null) {
+ if (!isEnabled) { return; }
+
+ try {
+ if (this._frontendSetupPromise === null) {
+ this._frontendSetupPromise = this._setupNestedFrontend();
+ }
+ await this._frontendSetupPromise;
+ } catch (e) {
+ yomichan.logError(e);
+ return;
+ } finally {
+ this._frontendSetupPromise = null;
+ }
+ }
+
+ this._frontend.setDisabledOverride(!isEnabled);
+ }
+
+ async _setupNestedFrontend() {
+ const setupNestedPopupsOptions = {
+ useProxyPopup: this._parentFrameId !== null,
+ parentPopupId: this._parentPopupId,
+ parentFrameId: this._parentFrameId
+ };
+
+ await dynamicLoader.loadScripts([
+ '/js/language/text-scanner.js',
+ '/js/comm/frame-client.js',
+ '/fg/js/popup.js',
+ '/fg/js/popup-proxy.js',
+ '/fg/js/popup-window.js',
+ '/fg/js/popup-factory.js',
+ '/fg/js/frame-ancestry-handler.js',
+ '/fg/js/frame-offset-forwarder.js',
+ '/fg/js/frontend.js'
+ ]);
+
+ const popupFactory = new PopupFactory(this._frameId);
+ popupFactory.prepare();
+
+ Object.assign(setupNestedPopupsOptions, {
+ depth: this._depth + 1,
+ tabId: this._tabId,
+ frameId: this._frameId,
+ popupFactory,
+ pageType: this._pageType,
+ allowRootFramePopupProxy: true,
+ childrenSupported: this._childrenSupported,
+ hotkeyHandler: this._hotkeyHandler
+ });
+
+ const frontend = new Frontend(setupNestedPopupsOptions);
+ this._frontend = frontend;
+ await frontend.prepare();
+ }
+
+ async _invokeContentOrigin(action, params={}) {
+ if (this._contentOriginTabId === this._tabId && this._contentOriginFrameId === this._frameId) {
+ throw new Error('Content origin is same page');
+ }
+ return await api.crossFrame.invokeTab(this._contentOriginTabId, this._contentOriginFrameId, action, params);
+ }
+
+ _copyHostSelection() {
+ if (this._contentOriginFrameId === null || window.getSelection().toString()) { return false; }
+ this._copyHostSelectionInner();
+ return true;
+ }
+
+ async _copyHostSelectionInner() {
+ switch (this._browser) {
+ case 'firefox':
+ case 'firefox-mobile':
+ {
+ let text;
+ try {
+ text = await this._invokeContentOrigin('getSelectionText');
+ } catch (e) {
+ break;
+ }
+ this._copyText(text);
+ }
+ break;
+ default:
+ await this._invokeContentOrigin('copySelection');
+ break;
+ }
+ }
+
+ _copyText(text) {
+ const parent = document.body;
+ if (parent === null) { return; }
+
+ let textarea = this._copyTextarea;
+ if (textarea === null) {
+ textarea = document.createElement('textarea');
+ this._copyTextarea = textarea;
+ }
+
+ textarea.value = text;
+ parent.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ parent.removeChild(textarea);
+ }
+
+ _addMultipleEventListeners(container, selector, ...args) {
+ for (const node of container.querySelectorAll(selector)) {
+ this._eventListeners.addEventListener(node, ...args);
+ }
+ }
+
+ _addEntryEventListeners(entry) {
+ this._eventListeners.addEventListener(entry, 'click', this._onEntryClick.bind(this));
+ this._addMultipleEventListeners(entry, '.action-add-note', 'click', this._onNoteAdd.bind(this));
+ this._addMultipleEventListeners(entry, '.action-view-note', 'click', this._onNoteView.bind(this));
+ this._addMultipleEventListeners(entry, '.kanji-link', 'click', this._onKanjiLookup.bind(this));
+ this._addMultipleEventListeners(entry, '.debug-log-link', 'click', this._onDebugLogClick.bind(this));
+ this._addMultipleEventListeners(entry, '.tag', 'click', this._onTagClick.bind(this));
+ }
+
+ _updateDefinitionTextScanner(options) {
+ if (!options.scanning.enablePopupSearch) {
+ if (this._definitionTextScanner !== null) {
+ this._definitionTextScanner.setEnabled(false);
+ }
+ return;
+ }
+
+ if (this._definitionTextScanner === null) {
+ this._definitionTextScanner = new TextScanner({
+ node: window,
+ getSearchContext: this._getSearchContext.bind(this),
+ documentUtil: this._documentUtil,
+ searchTerms: true,
+ searchKanji: false,
+ searchOnClick: true,
+ searchOnClickOnly: true
+ });
+ this._definitionTextScanner.includeSelector = '.click-scannable,.click-scannable *';
+ this._definitionTextScanner.excludeSelector = '.scan-disable,.scan-disable *';
+ this._definitionTextScanner.prepare();
+ this._definitionTextScanner.on('searched', this._onDefinitionTextScannerSearched.bind(this));
+ }
+
+ const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options;
+ this._definitionTextScanner.setOptions({
+ inputs: [{
+ include: 'mouse0',
+ exclude: '',
+ types: {mouse: true, pen: false, touch: false},
+ options: {
+ searchTerms: true,
+ searchKanji: true,
+ scanOnTouchMove: false,
+ scanOnPenHover: false,
+ scanOnPenPress: false,
+ scanOnPenRelease: false,
+ preventTouchScrolling: false
+ }
+ }],
+ deepContentScan: scanningOptions.deepDomScan,
+ selectText: false,
+ delay: scanningOptions.delay,
+ touchInputEnabled: false,
+ pointerEventsEnabled: false,
+ scanLength: scanningOptions.length,
+ layoutAwareScan: scanningOptions.layoutAwareScan,
+ preventMiddleMouse: false,
+ sentenceParsingOptions
+ });
+
+ this._definitionTextScanner.setEnabled(true);
+ }
+
+ _onDefinitionTextScannerSearched({type, definitions, sentence, textSource, optionsContext, error}) {
+ if (error !== null && !yomichan.isExtensionUnloaded) {
+ yomichan.logError(error);
+ }
+
+ if (type === null) { return; }
+
+ const query = textSource.text();
+ const url = window.location.href;
+ const documentTitle = document.title;
+ const details = {
+ focus: false,
+ history: true,
+ params: {
+ type,
+ query,
+ wildcards: 'off'
+ },
+ state: {
+ focusEntry: 0,
+ optionsContext,
+ url,
+ sentence,
+ documentTitle
+ },
+ content: {
+ definitions,
+ contentOrigin: this.getContentOrigin()
+ }
+ };
+ this._definitionTextScanner.clearSelection(true);
+ this.setContent(details);
+ }
+
+ _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);
+ }
+
+ _onFrameResizerMouseUp() {
+ this._stopFrameResize();
+ }
+
+ _onFrameResizerWindowBlur() {
+ this._stopFrameResize();
+ }
+
+ _onFrameResizerMouseMove(e) {
+ if ((e.buttons & 0x1) === 0x0) {
+ this._stopFrameResize();
+ } else {
+ if (this._frameResizeStartSize === null) { return; }
+ const {clientX: x, clientY: y} = e;
+ this._updateFrameSize(x, y);
+ }
+ }
+
+ _getSearchContext() {
+ return {optionsContext: this.getOptionsContext()};
+ }
+
+ _startFrameResize(e) {
+ if (this._frameResizeToken !== null) { return; }
+
+ const {clientX: x, clientY: y} = e;
+ const token = {};
+ this._frameResizeToken = token;
+ this._frameResizeStartOffset = {x, y};
+ this._frameResizeEventListeners.addEventListener(window, 'mouseup', this._onFrameResizerMouseUp.bind(this), false);
+ this._frameResizeEventListeners.addEventListener(window, 'blur', this._onFrameResizerWindowBlur.bind(this), false);
+ this._frameResizeEventListeners.addEventListener(window, 'mousemove', this._onFrameResizerMouseMove.bind(this), false);
+
+ const {documentElement} = document;
+ if (documentElement !== null) {
+ documentElement.dataset.isResizing = 'true';
+ }
+
+ this._initializeFrameResize(token);
+ }
+
+ async _initializeFrameResize(token) {
+ const size = await this._invokeContentOrigin('getFrameSize');
+ if (this._frameResizeToken !== token) { return; }
+ this._frameResizeStartSize = size;
+ }
+
+ _stopFrameResize() {
+ if (this._frameResizeToken === null) { return; }
+
+ this._frameResizeEventListeners.removeAllEventListeners();
+ this._frameResizeStartSize = null;
+ this._frameResizeStartOffset = null;
+ this._frameResizeToken = null;
+
+ const {documentElement} = document;
+ if (documentElement !== null) {
+ delete documentElement.dataset.isResizing;
+ }
+ }
+
+ async _updateFrameSize(x, y) {
+ const handleSize = this._frameResizeHandle.getBoundingClientRect();
+ let {width, height} = this._frameResizeStartSize;
+ width += x - this._frameResizeStartOffset.x;
+ height += y - this._frameResizeStartOffset.y;
+ width = Math.max(Math.max(0, handleSize.width), width);
+ height = Math.max(Math.max(0, handleSize.height), height);
+ await this._invokeContentOrigin('setFrameSize', {width, height});
+ }
+
+ _updateHotkeys(options) {
+ this._hotkeyHandler.setHotkeys(this._pageType, options.inputs.hotkeys);
+ }
+
+ async _closeTab() {
+ const tab = await new Promise((resolve, reject) => {
+ chrome.tabs.getCurrent((result) => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve(result);
+ }
+ });
+ });
+ const tabId = tab.id;
+ await new Promise((resolve, reject) => {
+ chrome.tabs.remove(tabId, () => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ _onHotkeyClose() {
+ if (this._closeSinglePopupMenu()) { return; }
+ this.close();
+ }
+
+ _closeAllPopupMenus() {
+ for (const popupMenu of PopupMenu.openMenus) {
+ popupMenu.close();
+ }
+ }
+
+ _closeSinglePopupMenu() {
+ for (const popupMenu of PopupMenu.openMenus) {
+ popupMenu.close();
+ return true;
+ }
+ return false;
+ }
+
+ _postProcessQuery(query) {
+ const queryPostProcessor = this._queryPostProcessor;
+ return typeof queryPostProcessor === 'function' ? queryPostProcessor(query) : query;
+ }
+}