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