diff options
Diffstat (limited to 'ext/mixed/js')
| -rw-r--r-- | ext/mixed/js/api.js | 8 | ||||
| -rw-r--r-- | ext/mixed/js/core.js | 17 | ||||
| -rw-r--r-- | ext/mixed/js/display-generator.js | 379 | ||||
| -rw-r--r-- | ext/mixed/js/display.js | 305 | ||||
| -rw-r--r-- | ext/mixed/js/japanese.js | 297 | ||||
| -rw-r--r-- | ext/mixed/js/text-scanner.js | 9 | 
6 files changed, 870 insertions, 145 deletions
| diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 8ed1d996..5ec93b01 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -101,6 +101,14 @@ function apiClipboardGet() {      return _apiInvoke('clipboardGet');  } +function apiGetDisplayTemplatesHtml() { +    return _apiInvoke('getDisplayTemplatesHtml'); +} + +function apiGetZoom() { +    return _apiInvoke('getZoom'); +} +  function _apiInvoke(action, params={}) {      const data = {action, params};      return new Promise((resolve, reject) => { diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 54e8a9d2..0142d594 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -56,7 +56,8 @@ function errorToJson(error) {      return {          name: error.name,          message: error.message, -        stack: error.stack +        stack: error.stack, +        data: error.data      };  } @@ -64,6 +65,7 @@ function jsonToError(jsonError) {      const error = new Error(jsonError.message);      error.name = jsonError.name;      error.stack = jsonError.stack; +    error.data = jsonError.data;      return error;  } @@ -74,7 +76,11 @@ function logError(error, alert) {      const errorString = `${error.toString ? error.toString() : error}`;      const stack = `${error.stack}`.trimRight(); -    errorMessage += (!stack.startsWith(errorString) ? `${errorString}\n${stack}` : `${stack}`); +    if (!stack.startsWith(errorString)) { errorMessage += `${errorString}\n`; } +    errorMessage += stack; + +    const data = error.data; +    if (typeof data !== 'undefined') { errorMessage += `\nData: ${JSON.stringify(data, null, 4)}`; }      errorMessage += '\n\nIssues can be reported at https://github.com/FooSoft/yomichan/issues'; @@ -238,7 +244,8 @@ const yomichan = (() => {              this._messageHandlers = new Map([                  ['getUrl', this._onMessageGetUrl.bind(this)], -                ['optionsUpdate', this._onMessageOptionsUpdate.bind(this)] +                ['optionsUpdate', this._onMessageOptionsUpdate.bind(this)], +                ['zoomChanged', this._onMessageZoomChanged.bind(this)]              ]);              chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); @@ -268,6 +275,10 @@ const yomichan = (() => {          _onMessageOptionsUpdate({source}) {              this.trigger('optionsUpdate', {source});          } + +        _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) { +            this.trigger('zoomChanged', {oldZoomFactor, newZoomFactor}); +        }      }      return new Yomichan(); diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js new file mode 100644 index 00000000..e1710488 --- /dev/null +++ b/ext/mixed/js/display-generator.js @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2019-2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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/>. + */ + + +class DisplayGenerator { +    constructor() { +        this._isInitialized = false; +        this._initializationPromise = null; + +        this._termEntryTemplate = null; +        this._termExpressionTemplate = null; +        this._termDefinitionItemTemplate = null; +        this._termDefinitionOnlyTemplate = null; +        this._termGlossaryItemTemplate = null; +        this._termReasonTemplate = null; + +        this._kanjiEntryTemplate = null; +        this._kanjiInfoTableTemplate = null; +        this._kanjiInfoTableItemTemplate = null; +        this._kanjiInfoTableEmptyTemplate = null; +        this._kanjiGlossaryItemTemplate = null; +        this._kanjiReadingTemplate = null; + +        this._tagTemplate = null; +        this._tagFrequencyTemplate = null; +    } + +    isInitialized() { +        return this._isInitialized; +    } + +    initialize() { +        if (this._isInitialized) { +            return Promise.resolve(); +        } +        if (this._initializationPromise === null) { +            this._initializationPromise = this._initializeInternal(); +        } +        return this._initializationPromise; +    } + +    createTermEntry(details) { +        const node = DisplayGenerator._instantiateTemplate(this._termEntryTemplate); + +        const expressionsContainer = node.querySelector('.term-expression-list'); +        const reasonsContainer = node.querySelector('.term-reasons'); +        const frequenciesContainer = node.querySelector('.frequencies'); +        const definitionsContainer = node.querySelector('.term-definition-list'); +        const debugInfoContainer = node.querySelector('.debug-info'); + +        const expressionMulti = Array.isArray(details.expressions); +        const definitionMulti = Array.isArray(details.definitions); + +        node.dataset.expressionMulti = `${expressionMulti}`; +        node.dataset.definitionMulti = `${definitionMulti}`; +        node.dataset.expressionCount = `${expressionMulti ? details.expressions.length : 1}`; +        node.dataset.definitionCount = `${definitionMulti ? details.definitions.length : 1}`; + +        DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), details.expressions, [details]); +        DisplayGenerator._appendMultiple(reasonsContainer, this.createTermReason.bind(this), details.reasons); +        DisplayGenerator._appendMultiple(frequenciesContainer, this.createFrequencyTag.bind(this), details.frequencies); +        DisplayGenerator._appendMultiple(definitionsContainer, this.createTermDefinitionItem.bind(this), details.definitions, [details]); + +        if (debugInfoContainer !== null) { +            debugInfoContainer.textContent = JSON.stringify(details, null, 4); +        } + +        return node; +    } + +    createTermExpression(details) { +        const node = DisplayGenerator._instantiateTemplate(this._termExpressionTemplate); + +        const expressionContainer = node.querySelector('.term-expression-text'); +        const tagContainer = node.querySelector('.tags'); +        const frequencyContainer = node.querySelector('.frequencies'); + +        if (details.termFrequency) { +            node.dataset.frequency = details.termFrequency; +        } + +        if (expressionContainer !== null) { +            let furiganaSegments = details.furiganaSegments; +            if (!Array.isArray(furiganaSegments)) { +                // This case should not occur +                furiganaSegments = [{text: details.expression, furigana: details.reading}]; +            } +            DisplayGenerator._appendFurigana(expressionContainer, furiganaSegments, this._appendKanjiLinks.bind(this)); +        } + +        DisplayGenerator._appendMultiple(tagContainer, this.createTag.bind(this), details.termTags); +        DisplayGenerator._appendMultiple(frequencyContainer, this.createFrequencyTag.bind(this), details.frequencies); + +        return node; +    } + +    createTermReason(reason) { +        const node = DisplayGenerator._instantiateTemplate(this._termReasonTemplate); +        node.textContent = reason; +        node.dataset.reason = reason; +        return node; +    } + +    createTermDefinitionItem(details) { +        const node = DisplayGenerator._instantiateTemplate(this._termDefinitionItemTemplate); + +        const tagListContainer = node.querySelector('.term-definition-tag-list'); +        const onlyListContainer = node.querySelector('.term-definition-only-list'); +        const glossaryContainer = node.querySelector('.term-glossary-list'); + +        node.dataset.dictionary = details.dictionary; + +        DisplayGenerator._appendMultiple(tagListContainer, this.createTag.bind(this), details.definitionTags); +        DisplayGenerator._appendMultiple(onlyListContainer, this.createTermOnly.bind(this), details.only); +        DisplayGenerator._appendMultiple(glossaryContainer, this.createTermGlossaryItem.bind(this), details.glossary); + +        return node; +    } + +    createTermGlossaryItem(glossary) { +        const node = DisplayGenerator._instantiateTemplate(this._termGlossaryItemTemplate); +        const container = node.querySelector('.term-glossary'); +        if (container !== null) { +            DisplayGenerator._appendMultilineText(container, glossary); +        } +        return node; +    } + +    createTermOnly(only) { +        const node = DisplayGenerator._instantiateTemplate(this._termDefinitionOnlyTemplate); +        node.dataset.only = only; +        node.textContent = only; +        return node; +    } + +    createKanjiLink(character) { +        const node = document.createElement('a'); +        node.href = '#'; +        node.className = 'kanji-link'; +        node.textContent = character; +        return node; +    } + +    createKanjiEntry(details) { +        const node = DisplayGenerator._instantiateTemplate(this._kanjiEntryTemplate); + +        const glyphContainer = node.querySelector('.kanji-glyph'); +        const frequenciesContainer = node.querySelector('.frequencies'); +        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'); +        const debugInfoContainer = node.querySelector('.debug-info'); + +        if (glyphContainer !== null) { +            glyphContainer.textContent = details.character; +        } + +        DisplayGenerator._appendMultiple(frequenciesContainer, this.createFrequencyTag.bind(this), details.frequencies); +        DisplayGenerator._appendMultiple(tagContainer, this.createTag.bind(this), details.tags); +        DisplayGenerator._appendMultiple(glossaryContainer, this.createKanjiGlossaryItem.bind(this), details.glossary); +        DisplayGenerator._appendMultiple(chineseReadingsContainer, this.createKanjiReading.bind(this), details.onyomi); +        DisplayGenerator._appendMultiple(japaneseReadingsContainer, this.createKanjiReading.bind(this), details.kunyomi); + +        if (statisticsContainer !== null) { +            statisticsContainer.appendChild(this.createKanjiInfoTable(details.stats.misc)); +        } +        if (classificationsContainer !== null) { +            classificationsContainer.appendChild(this.createKanjiInfoTable(details.stats.class)); +        } +        if (codepointsContainer !== null) { +            codepointsContainer.appendChild(this.createKanjiInfoTable(details.stats.code)); +        } +        if (dictionaryIndicesContainer !== null) { +            dictionaryIndicesContainer.appendChild(this.createKanjiInfoTable(details.stats.index)); +        } + +        if (debugInfoContainer !== null) { +            debugInfoContainer.textContent = JSON.stringify(details, null, 4); +        } + +        return node; +    } + +    createKanjiGlossaryItem(glossary) { +        const node = DisplayGenerator._instantiateTemplate(this._kanjiGlossaryItemTemplate); +        const container = node.querySelector('.kanji-glossary'); +        if (container !== null) { +            DisplayGenerator._appendMultilineText(container, glossary); +        } +        return node; +    } + +    createKanjiReading(reading) { +        const node = DisplayGenerator._instantiateTemplate(this._kanjiReadingTemplate); +        node.textContent = reading; +        return node; +    } + +    createKanjiInfoTable(details) { +        const node = DisplayGenerator._instantiateTemplate(this._kanjiInfoTableTemplate); + +        const container = node.querySelector('.kanji-info-table-body'); + +        if (container !== null) { +            const count = DisplayGenerator._appendMultiple(container, this.createKanjiInfoTableItem.bind(this), details); +            if (count === 0) { +                const n = this.createKanjiInfoTableItemEmpty(); +                container.appendChild(n); +            } +        } + +        return node; +    } + +    createKanjiInfoTableItem(details) { +        const node = DisplayGenerator._instantiateTemplate(this._kanjiInfoTableItemTemplate); +        const nameNode = node.querySelector('.kanji-info-table-item-header'); +        const valueNode = node.querySelector('.kanji-info-table-item-value'); +        if (nameNode !== null) { +            nameNode.textContent = details.notes || details.name; +        } +        if (valueNode !== null) { +            valueNode.textContent = details.value; +        } +        return node; +    } + +    createKanjiInfoTableItemEmpty() { +        return DisplayGenerator._instantiateTemplate(this._kanjiInfoTableEmptyTemplate); +    } + +    createTag(details) { +        const node = DisplayGenerator._instantiateTemplate(this._tagTemplate); + +        node.title = details.notes; +        node.textContent = details.name; +        node.dataset.category = details.category; + +        return node; +    } + +    createFrequencyTag(details) { +        const node = DisplayGenerator._instantiateTemplate(this._tagFrequencyTemplate); + +        let n = node.querySelector('.term-frequency-dictionary-name'); +        if (n !== null) { +            n.textContent = details.dictionary; +        } + +        n = node.querySelector('.term-frequency-value'); +        if (n !== null) { +            n.textContent = `${details.frequency}`; +        } + +        node.dataset.dictionary = details.dictionary; +        node.dataset.frequency = details.frequency; + +        return node; +    } + +    async _initializeInternal() { +        const html = await apiGetDisplayTemplatesHtml(); +        const doc = new DOMParser().parseFromString(html, 'text/html'); +        this._setTemplates(doc); +    } + +    _setTemplates(doc) { +        this._termEntryTemplate = doc.querySelector('#term-entry-template'); +        this._termExpressionTemplate = doc.querySelector('#term-expression-template'); +        this._termDefinitionItemTemplate = doc.querySelector('#term-definition-item-template'); +        this._termDefinitionOnlyTemplate = doc.querySelector('#term-definition-only-template'); +        this._termGlossaryItemTemplate = doc.querySelector('#term-glossary-item-template'); +        this._termReasonTemplate = doc.querySelector('#term-reason-template'); + +        this._kanjiEntryTemplate = doc.querySelector('#kanji-entry-template'); +        this._kanjiInfoTableTemplate = doc.querySelector('#kanji-info-table-template'); +        this._kanjiInfoTableItemTemplate = doc.querySelector('#kanji-info-table-item-template'); +        this._kanjiInfoTableEmptyTemplate = doc.querySelector('#kanji-info-table-empty-template'); +        this._kanjiGlossaryItemTemplate = doc.querySelector('#kanji-glossary-item-template'); +        this._kanjiReadingTemplate = doc.querySelector('#kanji-reading-template'); + +        this._tagTemplate = doc.querySelector('#tag-template'); +        this._tagFrequencyTemplate = doc.querySelector('#tag-frequency-template'); +    } + +    _appendKanjiLinks(container, text) { +        let part = ''; +        for (const c of text) { +            if (DisplayGenerator._isCharacterKanji(c)) { +                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)); +        } +    } + +    static _isCharacterKanji(c) { +        const code = c.charCodeAt(0); +        return ( +            code >= 0x4e00 && code < 0x9fb0 || +            code >= 0x3400 && code < 0x4dc0 +        ); +    } + +    static _appendMultiple(container, createItem, detailsArray, fallback=[]) { +        if (container === null) { return 0; } + +        const isArray = Array.isArray(detailsArray); +        if (!isArray) { detailsArray = fallback; } + +        container.dataset.multi = `${isArray}`; +        container.dataset.count = `${detailsArray.length}`; + +        for (const details of detailsArray) { +            const item = createItem(details); +            if (item === null) { continue; } +            container.appendChild(item); +        } + +        return detailsArray.length; +    } + +    static _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); +            } +        } +    } + +    static _appendMultilineText(container, text) { +        const parts = text.split('\n'); +        container.appendChild(document.createTextNode(parts[0])); +        for (let i = 1, ii = parts.length; i < ii; ++i) { +            container.appendChild(document.createElement('br')); +            container.appendChild(document.createTextNode(parts[i])); +        } +    } + +    static _instantiateTemplate(template) { +        return document.importNode(template.content.firstChild, true); +    } +} diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index e756f948..c4be02f2 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -24,7 +24,6 @@ class Display {          this.definitions = [];          this.options = null;          this.context = null; -        this.sequence = 0;          this.index = 0;          this.audioPlaying = null;          this.audioFallback = null; @@ -36,7 +35,9 @@ class Display {          this.interactive = false;          this.eventListenersActive = false;          this.clickScanPrevent = false; +        this.setContentToken = null; +        this.displayGenerator = new DisplayGenerator();          this.windowScroll = new WindowScroll();          this.setInteractive(true); @@ -76,7 +77,7 @@ class Display {              };              const definitions = await apiKanjiFind(link.textContent, this.getOptionsContext()); -            this.setContentKanji(definitions, context); +            this.setContent('kanji', {definitions, context});          } catch (error) {              this.onError(error);          } @@ -130,7 +131,7 @@ class Display {                  });              } -            this.setContentTerms(definitions, context); +            this.setContent('terms', {definitions, context});              if (selectText) {                  textSource.select(); @@ -174,7 +175,7 @@ class Display {          const link = e.currentTarget;          const entry = link.closest('.entry');          const definitionIndex = this.entryIndexFind(entry); -        const expressionIndex = Display.indexOf(entry.querySelectorAll('.expression .action-play-audio'), link); +        const expressionIndex = Display.indexOf(entry.querySelectorAll('.term-expression .action-play-audio'), link);          this.audioPlay(this.definitions[definitionIndex], expressionIndex, definitionIndex);      } @@ -240,11 +241,20 @@ class Display {      async updateOptions(options) {          this.options = options ? options : await apiOptionsGet(this.getOptionsContext()); +        this.updateDocumentOptions(this.options);          this.updateTheme(this.options.general.popupTheme);          this.setCustomCss(this.options.general.customPopupCss);          audioPrepareTextToSpeech(this.options);      } +    updateDocumentOptions(options) { +        const data = document.documentElement.dataset; +        data.ankiEnabled = `${options.anki.enable}`; +        data.audioEnabled = `${options.audio.enable}`; +        data.compactGlossaries = `${options.general.compactGlossaries}`; +        data.debug = `${options.general.debugInfo}`; +    } +      updateTheme(themeName) {          document.documentElement.dataset.yomichanTheme = themeName; @@ -277,6 +287,9 @@ class Display {          if (interactive) {              Display.addEventListener(this.persistentEventListeners, document, 'keydown', this.onKeyDown.bind(this), false);              Display.addEventListener(this.persistentEventListeners, document, 'wheel', this.onWheel.bind(this), {passive: false}); +            Display.addEventListener(this.persistentEventListeners, document.querySelector('.action-previous'), 'click', this.onSourceTermView.bind(this)); +            Display.addEventListener(this.persistentEventListeners, document.querySelector('.action-next'), 'click', this.onNextTermView.bind(this)); +            Display.addEventListener(this.persistentEventListeners, document.querySelector('.navigation-header'), 'wheel', this.onHistoryWheel.bind(this), {passive: false});          } else {              Display.clearEventListeners(this.persistentEventListeners);          } @@ -293,9 +306,6 @@ class Display {              this.addEventListeners('.action-view-note', 'click', this.onNoteView.bind(this));              this.addEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this));              this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this)); -            this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this)); -            this.addEventListeners('.next-term', 'click', this.onNextTermView.bind(this)); -            this.addEventListeners('.term-navigation', 'wheel', this.onHistoryWheel.bind(this), {passive: false});              if (this.options.scanning.enablePopupSearch) {                  this.addEventListeners('.glossary-item', 'mouseup', this.onGlossaryMouseUp.bind(this));                  this.addEventListeners('.glossary-item', 'mousedown', this.onGlossaryMouseDown.bind(this)); @@ -312,138 +322,178 @@ class Display {          }      } -    setContent(type, details) { -        switch (type) { -            case 'terms': -                return this.setContentTerms(details.definitions, details.context); -            case 'kanji': -                return this.setContentKanji(details.definitions, details.context); -            case 'orphaned': -                return this.setContentOrphaned(); -            default: -                return Promise.resolve(); +    async setContent(type, details) { +        const token = {}; // Unique identifier token +        this.setContentToken = token; +        try { +            switch (type) { +                case 'terms': +                    await this.setContentTerms(details.definitions, details.context, token); +                    break; +                case 'kanji': +                    await this.setContentKanji(details.definitions, details.context, token); +                    break; +                case 'orphaned': +                    this.setContentOrphaned(); +                    break; +            } +        } catch (e) { +            this.onError(e); +        } finally { +            if (this.setContentToken === token) { +                this.setContentToken = null; +            }          }      } -    async setContentTerms(definitions, context) { +    async setContentTerms(definitions, context, token) {          if (!context) { throw new Error('Context expected'); }          if (!this.isInitialized()) { return; } -        try { -            const options = this.options; +        this.setEventListenersActive(false); -            this.setEventListenersActive(false); +        if (context.focus !== false) { +            window.focus(); +        } -            if (context.focus !== false) { -                window.focus(); -            } +        if (!this.displayGenerator.isInitialized()) { +            await this.displayGenerator.initialize(); +            if (this.setContentToken !== token) { return; } +        } -            this.definitions = definitions; -            if (context.disableHistory) { -                delete context.disableHistory; -                this.context = new DisplayContext('terms', definitions, context); -            } else { -                this.context = DisplayContext.push(this.context, 'terms', definitions, context); -            } +        this.definitions = definitions; +        if (context.disableHistory) { +            delete context.disableHistory; +            this.context = new DisplayContext('terms', definitions, context); +        } else { +            this.context = DisplayContext.push(this.context, 'terms', definitions, context); +        } -            const sequence = ++this.sequence; -            const params = { -                definitions, -                source: !!this.context.previous, -                next: !!this.context.next, -                addable: options.anki.enable, -                grouped: options.general.resultOutputMode === 'group', -                merged: options.general.resultOutputMode === 'merge', -                playback: options.audio.enabled, -                compactGlossaries: options.general.compactGlossaries, -                debug: options.general.debugInfo -            }; +        for (const definition of definitions) { +            definition.cloze = Display.clozeBuild(context.sentence, definition.source); +            definition.url = context.url; +        } -            for (const definition of definitions) { -                definition.cloze = Display.clozeBuild(context.sentence, definition.source); -                definition.url = context.url; -            } +        this.updateNavigation(this.context.previous, this.context.next); +        this.setNoContentVisible(definitions.length === 0); -            const content = await apiTemplateRender('terms.html', params); -            this.container.innerHTML = content; -            const {index, scroll, disableScroll} = context; -            if (!disableScroll) { -                this.entryScrollIntoView(index || 0, scroll); -            } else { -                delete context.disableScroll; -                this.entrySetCurrent(index || 0); -            } +        const container = this.container; +        container.textContent = ''; -            if (options.audio.enabled && options.audio.autoPlay) { -                this.autoPlayAudio(); +        for (let i = 0, ii = definitions.length; i < ii; ++i) { +            if (i > 0) { +                await promiseTimeout(1); +                if (this.setContentToken !== token) { return; }              } -            this.setEventListenersActive(true); +            const entry = this.displayGenerator.createTermEntry(definitions[i]); +            container.appendChild(entry); +        } -            await this.adderButtonUpdate(['term-kanji', 'term-kana'], sequence); -        } catch (e) { -            this.onError(e); +        const {index, scroll, disableScroll} = context; +        if (!disableScroll) { +            this.entryScrollIntoView(index || 0, scroll); +        } else { +            delete context.disableScroll; +            this.entrySetCurrent(index || 0);          } + +        if (this.options.audio.enabled && this.options.audio.autoPlay) { +            this.autoPlayAudio(); +        } + +        this.setEventListenersActive(true); + +        const states = await apiDefinitionsAddable(definitions, ['term-kanji', 'term-kana'], this.getOptionsContext()); +        if (this.setContentToken !== token) { return; } + +        this.updateAdderButtons(states);      } -    async setContentKanji(definitions, context) { +    async setContentKanji(definitions, context, token) {          if (!context) { throw new Error('Context expected'); }          if (!this.isInitialized()) { return; } -        try { -            const options = this.options; +        this.setEventListenersActive(false); -            this.setEventListenersActive(false); +        if (context.focus !== false) { +            window.focus(); +        } -            if (context.focus !== false) { -                window.focus(); -            } +        if (!this.displayGenerator.isInitialized()) { +            await this.displayGenerator.initialize(); +            if (this.setContentToken !== token) { return; } +        } -            this.definitions = definitions; -            if (context.disableHistory) { -                delete context.disableHistory; -                this.context = new DisplayContext('kanji', definitions, context); -            } else { -                this.context = DisplayContext.push(this.context, 'kanji', definitions, context); -            } +        this.definitions = definitions; +        if (context.disableHistory) { +            delete context.disableHistory; +            this.context = new DisplayContext('kanji', definitions, context); +        } else { +            this.context = DisplayContext.push(this.context, 'kanji', definitions, context); +        } -            const sequence = ++this.sequence; -            const params = { -                definitions, -                source: !!this.context.previous, -                next: !!this.context.next, -                addable: options.anki.enable, -                debug: options.general.debugInfo -            }; +        for (const definition of definitions) { +            definition.cloze = Display.clozeBuild(context.sentence, definition.character); +            definition.url = context.url; +        } -            for (const definition of definitions) { -                definition.cloze = Display.clozeBuild(context.sentence, definition.character); -                definition.url = context.url; -            } +        this.updateNavigation(this.context.previous, this.context.next); +        this.setNoContentVisible(definitions.length === 0); -            const content = await apiTemplateRender('kanji.html', params); -            this.container.innerHTML = content; -            const {index, scroll} = context; -            this.entryScrollIntoView(index || 0, scroll); +        const container = this.container; +        container.textContent = ''; -            this.setEventListenersActive(true); +        for (let i = 0, ii = definitions.length; i < ii; ++i) { +            if (i > 0) { +                await promiseTimeout(0); +                if (this.setContentToken !== token) { return; } +            } -            await this.adderButtonUpdate(['kanji'], sequence); -        } catch (e) { -            this.onError(e); +            const entry = this.displayGenerator.createKanjiEntry(definitions[i]); +            container.appendChild(entry);          } + +        const {index, scroll} = context; +        this.entryScrollIntoView(index || 0, scroll); + +        this.setEventListenersActive(true); + +        const states = await apiDefinitionsAddable(definitions, ['kanji'], this.getOptionsContext()); +        if (this.setContentToken !== token) { return; } + +        this.updateAdderButtons(states);      } -    async setContentOrphaned() { -        const definitions = document.querySelector('#definitions'); +    setContentOrphaned() {          const errorOrphaned = document.querySelector('#error-orphaned'); -        if (definitions !== null) { -            definitions.style.setProperty('display', 'none', 'important'); +        if (this.container !== null) { +            this.container.hidden = true;          }          if (errorOrphaned !== null) { -            errorOrphaned.style.setProperty('display', 'block', 'important'); +            errorOrphaned.hidden = false; +        } + +        this.updateNavigation(null, null); +        this.setNoContentVisible(false); +    } + +    setNoContentVisible(visible) { +        const noResults = document.querySelector('#no-results'); + +        if (noResults !== null) { +            noResults.hidden = !visible; +        } +    } + +    updateNavigation(previous, next) { +        const navigation = document.querySelector('#navigation-header'); +        if (navigation !== null) { +            navigation.hidden = !(previous || next); +            navigation.dataset.hasPrevious = `${!!previous}`; +            navigation.dataset.hasNext = `${!!next}`;          }      } @@ -451,35 +501,26 @@ class Display {          this.audioPlay(this.definitions[0], this.firstExpressionIndex, 0);      } -    async adderButtonUpdate(modes, sequence) { -        try { -            const states = await apiDefinitionsAddable(this.definitions, modes, this.getOptionsContext()); -            if (!states || sequence !== this.sequence) { -                return; -            } - -            for (let i = 0; i < states.length; ++i) { -                const state = states[i]; -                let noteId = null; -                for (const mode in state) { -                    const button = this.adderButtonFind(i, mode); -                    if (button === null) { -                        continue; -                    } - -                    const info = state[mode]; -                    if (!info.canAdd && noteId === null && info.noteId) { -                        noteId = info.noteId; -                    } -                    button.classList.toggle('disabled', !info.canAdd); -                    button.classList.remove('pending'); +    updateAdderButtons(states) { +        for (let i = 0; i < states.length; ++i) { +            const state = states[i]; +            let noteId = null; +            for (const mode in state) { +                const button = this.adderButtonFind(i, mode); +                if (button === null) { +                    continue;                  } -                if (noteId !== null) { -                    this.viewerButtonShow(i, noteId); + +                const info = state[mode]; +                if (!info.canAdd && noteId === null && info.noteId) { +                    noteId = info.noteId;                  } +                button.classList.toggle('disabled', !info.canAdd); +                button.classList.remove('pending'); +            } +            if (noteId !== null) { +                this.viewerButtonShow(i, noteId);              } -        } catch (e) { -            this.onError(e);          }      } @@ -511,6 +552,11 @@ class Display {              target = scroll;          } else {              target = this.index === 0 || entry === null ? 0 : Display.getElementTop(entry); + +            const header = document.querySelector('#navigation-header'); +            if (header !== null) { +                target -= header.getBoundingClientRect().height; +            }          }          if (smooth) { @@ -673,7 +719,9 @@ class Display {      }      setSpinnerVisible(visible) { -        this.spinner.style.display = visible ? 'block' : ''; +        if (this.spinner !== null) { +            this.spinner.hidden = !visible; +        }      }      getEntry(index) { @@ -733,6 +781,7 @@ class Display {      }      static addEventListener(eventListeners, object, type, listener, options) { +        if (object === null) { return; }          object.addEventListener(type, listener, options);          eventListeners.push([object, type, listener, options]);      } diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js index 23b2bd36..0da822d7 100644 --- a/ext/mixed/js/japanese.js +++ b/ext/mixed/js/japanese.js @@ -17,24 +17,153 @@   */ -function jpIsKanji(c) { -    const code = c.charCodeAt(0); -    return code >= 0x4e00 && code < 0x9fb0 || code >= 0x3400 && code < 0x4dc0; +const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([ +    ['ヲ', 'ヲヺ-'], +    ['ァ', 'ァ--'], +    ['ィ', 'ィ--'], +    ['ゥ', 'ゥ--'], +    ['ェ', 'ェ--'], +    ['ォ', 'ォ--'], +    ['ャ', 'ャ--'], +    ['ュ', 'ュ--'], +    ['ョ', 'ョ--'], +    ['ッ', 'ッ--'], +    ['ー', 'ー--'], +    ['ア', 'ア--'], +    ['イ', 'イ--'], +    ['ウ', 'ウヴ-'], +    ['エ', 'エ--'], +    ['オ', 'オ--'], +    ['カ', 'カガ-'], +    ['キ', 'キギ-'], +    ['ク', 'クグ-'], +    ['ケ', 'ケゲ-'], +    ['コ', 'コゴ-'], +    ['サ', 'サザ-'], +    ['シ', 'シジ-'], +    ['ス', 'スズ-'], +    ['セ', 'セゼ-'], +    ['ソ', 'ソゾ-'], +    ['タ', 'タダ-'], +    ['チ', 'チヂ-'], +    ['ツ', 'ツヅ-'], +    ['テ', 'テデ-'], +    ['ト', 'トド-'], +    ['ナ', 'ナ--'], +    ['ニ', 'ニ--'], +    ['ヌ', 'ヌ--'], +    ['ネ', 'ネ--'], +    ['ノ', 'ノ--'], +    ['ハ', 'ハバパ'], +    ['ヒ', 'ヒビピ'], +    ['フ', 'フブプ'], +    ['ヘ', 'ヘベペ'], +    ['ホ', 'ホボポ'], +    ['マ', 'マ--'], +    ['ミ', 'ミ--'], +    ['ム', 'ム--'], +    ['メ', 'メ--'], +    ['モ', 'モ--'], +    ['ヤ', 'ヤ--'], +    ['ユ', 'ユ--'], +    ['ヨ', 'ヨ--'], +    ['ラ', 'ラ--'], +    ['リ', 'リ--'], +    ['ル', 'ル--'], +    ['レ', 'レ--'], +    ['ロ', 'ロ--'], +    ['ワ', 'ワ--'], +    ['ン', 'ン--'] +]); + +const JP_HIRAGANA_RANGE = [0x3040, 0x309f]; +const JP_KATAKANA_RANGE = [0x30a0, 0x30ff]; +const JP_KANA_RANGES = [JP_HIRAGANA_RANGE, JP_KATAKANA_RANGE]; + +const JP_CJK_COMMON_RANGE = [0x4e00, 0x9fff]; +const JP_CJK_RARE_RANGE = [0x3400, 0x4dbf]; +const JP_CJK_RANGES = [JP_CJK_COMMON_RANGE, JP_CJK_RARE_RANGE]; + +const JP_ITERATION_MARK_CHAR_CODE = 0x3005; + +// Japanese character ranges, roughly ordered in order of expected frequency +const JP_JAPANESE_RANGES = [ +    JP_HIRAGANA_RANGE, +    JP_KATAKANA_RANGE, + +    JP_CJK_COMMON_RANGE, +    JP_CJK_RARE_RANGE, + +    [0xff66, 0xff9f], // Halfwidth katakana + +    [0x30fb, 0x30fc], // Katakana punctuation +    [0xff61, 0xff65], // Kana punctuation +    [0x3000, 0x303f], // CJK punctuation + +    [0xff10, 0xff19], // Fullwidth numbers +    [0xff21, 0xff3a], // Fullwidth upper case Latin letters +    [0xff41, 0xff5a], // Fullwidth lower case Latin letters + +    [0xff01, 0xff0f], // Fullwidth punctuation 1 +    [0xff1a, 0xff1f], // Fullwidth punctuation 2 +    [0xff3b, 0xff3f], // Fullwidth punctuation 3 +    [0xff5b, 0xff60], // Fullwidth punctuation 4 +    [0xffe0, 0xffee], // Currency markers +]; + + +// Helper functions + +function _jpIsCharCodeInRanges(charCode, ranges) { +    for (const [min, max] of ranges) { +        if (charCode >= min && charCode <= max) { +            return true; +        } +    } +    return false;  } -function jpIsKana(c) { -    return wanakana.isKana(c); + +// Character code testing functions + +function jpIsCharCodeKanji(charCode) { +    return _jpIsCharCodeInRanges(charCode, JP_CJK_RANGES);  } -function jpIsJapaneseText(text) { -    for (const c of text) { -        if (jpIsKanji(c) || jpIsKana(c)) { +function jpIsCharCodeKana(charCode) { +    return _jpIsCharCodeInRanges(charCode, JP_KANA_RANGES); +} + +function jpIsCharCodeJapanese(charCode) { +    return _jpIsCharCodeInRanges(charCode, JP_JAPANESE_RANGES); +} + + +// String testing functions + +function jpIsStringEntirelyKana(str) { +    if (str.length === 0) { return false; } +    for (let i = 0, ii = str.length; i < ii; ++i) { +        if (!jpIsCharCodeKana(str.charCodeAt(i))) { +            return false; +        } +    } +    return true; +} + +function jpIsStringPartiallyJapanese(str) { +    if (str.length === 0) { return false; } +    for (let i = 0, ii = str.length; i < ii; ++i) { +        if (jpIsCharCodeJapanese(str.charCodeAt(i))) {              return true;          }      }      return false;  } + +// Conversion functions +  function jpKatakanaToHiragana(text) {      let result = '';      for (const c of text) { @@ -75,11 +204,13 @@ function jpConvertReading(expressionFragment, readingFragment, readingMode) {              if (readingFragment) {                  return jpToRomaji(readingFragment);              } else { -                if (jpIsKana(expressionFragment)) { +                if (jpIsStringEntirelyKana(expressionFragment)) {                      return jpToRomaji(expressionFragment);                  }              }              return readingFragment; +        case 'none': +            return null;          default:              return readingFragment;      } @@ -132,7 +263,8 @@ function jpDistributeFurigana(expression, reading) {      const groups = [];      let modePrev = null;      for (const c of expression) { -        const modeCurr = jpIsKanji(c) || c.charCodeAt(0) === 0x3005 /* noma */ ? 'kanji' : 'kana'; +        const charCode = c.charCodeAt(0); +        const modeCurr = jpIsCharCodeKanji(charCode) || charCode === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana';          if (modeCurr === modePrev) {              groups[groups.length - 1].text += c;          } else { @@ -175,3 +307,148 @@ function jpDistributeFuriganaInflected(expression, reading, source) {      return output;  } + +function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) { +    let result = ''; +    const ii = text.length; +    const hasSourceMapping = Array.isArray(sourceMapping); + +    for (let i = 0; i < ii; ++i) { +        const c = text[i]; +        const mapping = JP_HALFWIDTH_KATAKANA_MAPPING.get(c); +        if (typeof mapping !== 'string') { +            result += c; +            continue; +        } + +        let index = 0; +        switch (text.charCodeAt(i + 1)) { +            case 0xff9e: // dakuten +                index = 1; +                break; +            case 0xff9f: // handakuten +                index = 2; +                break; +        } + +        let c2 = mapping[index]; +        if (index > 0) { +            if (c2 === '-') { // invalid +                index = 0; +                c2 = mapping[0]; +            } else { +                ++i; +            } +        } + +        if (hasSourceMapping && index > 0) { +            index = result.length; +            const v = sourceMapping.splice(index + 1, 1)[0]; +            sourceMapping[index] += v; +        } +        result += c2; +    } + +    return result; +} + +function jpConvertNumericTofullWidth(text) { +    let result = ''; +    for (let i = 0, ii = text.length; i < ii; ++i) { +        let c = text.charCodeAt(i); +        if (c >= 0x30 && c <= 0x39) { // ['0', '9'] +            c += 0xff10 - 0x30; // 0xff10 = '0' full width +            result += String.fromCharCode(c); +        } else { +            result += text[i]; +        } +    } +    return result; +} + +function jpConvertAlphabeticToKana(text, sourceMapping) { +    let part = ''; +    let result = ''; +    const ii = text.length; + +    if (sourceMapping.length === ii) { +        sourceMapping.length = ii; +        sourceMapping.fill(1); +    } + +    for (let i = 0; i < ii; ++i) { +        // Note: 0x61 is the character code for 'a' +        let c = text.charCodeAt(i); +        if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] +            c += (0x61 - 0x41); +        } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] +            // NOP; c += (0x61 - 0x61); +        } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth +            c += (0x61 - 0xff21); +        } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth +            c += (0x61 - 0xff41); +        } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash +            c = 0x2d; // '-' +        } else { +            if (part.length > 0) { +                result += jpToHiragana(part, sourceMapping, result.length); +                part = ''; +            } +            result += text[i]; +            continue; +        } +        part += String.fromCharCode(c); +    } + +    if (part.length > 0) { +        result += jpToHiragana(part, sourceMapping, result.length); +    } +    return result; +} + +function jpToHiragana(text, sourceMapping, sourceMappingStart) { +    const result = wanakana.toHiragana(text); + +    // Generate source mapping +    if (Array.isArray(sourceMapping)) { +        if (typeof sourceMappingStart !== 'number') { sourceMappingStart = 0; } +        let i = 0; +        let resultPos = 0; +        const ii = text.length; +        while (i < ii) { +            // Find smallest matching substring +            let iNext = i + 1; +            let resultPosNext = result.length; +            while (iNext < ii) { +                const t = wanakana.toHiragana(text.substring(0, iNext)); +                if (t === result.substring(0, t.length)) { +                    resultPosNext = t.length; +                    break; +                } +                ++iNext; +            } + +            // Merge characters +            const removals = iNext - i - 1; +            if (removals > 0) { +                let sum = 0; +                const vs = sourceMapping.splice(sourceMappingStart + 1, removals); +                for (const v of vs) { sum += v; } +                sourceMapping[sourceMappingStart] += sum; +            } +            ++sourceMappingStart; + +            // Empty elements +            const additions = resultPosNext - resultPos - 1; +            for (let j = 0; j < additions; ++j) { +                sourceMapping.splice(sourceMappingStart, 0, 0); +                ++sourceMappingStart; +            } + +            i = iNext; +            resultPos = resultPosNext; +        } +    } + +    return result; +} diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index a05dd2ee..88f1e27a 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -281,6 +281,7 @@ class TextScanner {      setOptions(options) {          this.options = options; +        this.setEnabled(this.options.general.enable);      }      async searchAt(x, y, cause) { @@ -298,11 +299,11 @@ class TextScanner {              }              const textSource = docRangeFromPoint(x, y, this.options.scanning.deepDomScan); -            if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) { -                return; -            } -              try { +                if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) { +                    return; +                } +                  this.pendingLookup = true;                  const result = await this.onSearchSource(textSource, cause);                  if (result !== null) { |