From 6a271e067fa917614f4c81f473533e24c6d04404 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sat, 13 Feb 2021 22:52:28 -0500 Subject: 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 --- ext/js/display/display-audio.js | 544 ++++++++ ext/js/display/display-generator.js | 702 ++++++++++ ext/js/display/display-history.js | 178 +++ ext/js/display/display-notification.js | 95 ++ ext/js/display/display-profile-selection.js | 106 ++ ext/js/display/display.js | 1886 +++++++++++++++++++++++++++ 6 files changed, 3511 insertions(+) create mode 100644 ext/js/display/display-audio.js create mode 100644 ext/js/display/display-generator.js create mode 100644 ext/js/display/display-history.js create mode 100644 ext/js/display/display-notification.js create mode 100644 ext/js/display/display-profile-selection.js create mode 100644 ext/js/display/display.js (limited to 'ext/js/display') 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 . + */ + +/* 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 . + */ + +/* 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 . + */ + +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 . + */ + +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 . + */ + +/* 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 . + */ + +/* 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; + } +} -- cgit v1.2.3