diff options
| author | Alex Yatskov <alex@foosoft.net> | 2020-04-10 09:38:07 -0700 | 
|---|---|---|
| committer | Alex Yatskov <alex@foosoft.net> | 2020-04-10 09:38:07 -0700 | 
| commit | 3ed49205f2af076e3c5b4fe371d8a0a420845581 (patch) | |
| tree | ab0c0fd9638aaa6a842bc4f17e73754ca7d26bd9 /ext/mixed/js | |
| parent | b77e2afe3a8ef9e96a53dd8ca97d8b913941244b (diff) | |
| parent | 281023095a9fb7f7aca1df8dc0e3f902e78dc16b (diff) | |
Merge branch 'master' into testing
Diffstat (limited to 'ext/mixed/js')
| -rw-r--r-- | ext/mixed/js/api.js | 8 | ||||
| -rw-r--r-- | ext/mixed/js/core.js | 75 | ||||
| -rw-r--r-- | ext/mixed/js/display-generator.js | 257 | ||||
| -rw-r--r-- | ext/mixed/js/display.js | 42 | ||||
| -rw-r--r-- | ext/mixed/js/japanese.js | 148 | ||||
| -rw-r--r-- | ext/mixed/js/object-property-accessor.js | 244 | ||||
| -rw-r--r-- | ext/mixed/js/text-scanner.js | 7 | 
7 files changed, 737 insertions, 44 deletions
| diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 0ab07039..feec94df 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -53,12 +53,12 @@ function apiKanjiFind(text, optionsContext) {      return _apiInvoke('kanjiFind', {text, optionsContext});  } -function apiDefinitionAdd(definition, mode, context, optionsContext) { -    return _apiInvoke('definitionAdd', {definition, mode, context, optionsContext}); +function apiDefinitionAdd(definition, mode, context, details, optionsContext) { +    return _apiInvoke('definitionAdd', {definition, mode, context, details, optionsContext});  } -function apiDefinitionsAddable(definitions, modes, optionsContext) { -    return _apiInvoke('definitionsAddable', {definitions, modes, optionsContext}); +function apiDefinitionsAddable(definitions, modes, context, optionsContext) { +    return _apiInvoke('definitionsAddable', {definitions, modes, context, optionsContext});  }  function apiNoteView(noteId) { diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 0d50e915..db7fc69b 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -132,6 +132,30 @@ function parseUrl(url) {      return {baseUrl, queryParams};  } +function areSetsEqual(set1, set2) { +    if (set1.size !== set2.size) { +        return false; +    } + +    for (const value of set1) { +        if (!set2.has(value)) { +            return false; +        } +    } + +    return true; +} + +function getSetIntersection(set1, set2) { +    const result = []; +    for (const value of set1) { +        if (set2.has(value)) { +            result.push(value); +        } +    } +    return result; +} +  /*   * Async utilities @@ -254,11 +278,16 @@ const yomichan = (() => {          constructor() {              super(); -            this._isBackendPreparedResolve = null; -            this._isBackendPreparedPromise = new Promise((resolve) => (this._isBackendPreparedResolve = resolve)); +            this._isBackendPreparedPromise = this.getTemporaryListenerResult( +                chrome.runtime.onMessage, +                ({action}, {resolve}) => { +                    if (action === 'backendPrepared') { +                        resolve(); +                    } +                } +            );              this._messageHandlers = new Map([ -                ['backendPrepared', this._onBackendPrepared.bind(this)],                  ['getUrl', this._onMessageGetUrl.bind(this)],                  ['optionsUpdated', this._onMessageOptionsUpdated.bind(this)],                  ['zoomChanged', this._onMessageZoomChanged.bind(this)] @@ -288,6 +317,42 @@ const yomichan = (() => {              this.trigger('orphaned', {error});          } +        getTemporaryListenerResult(eventHandler, userCallback, timeout=null) { +            if (!( +                typeof eventHandler.addListener === 'function' && +                typeof eventHandler.removeListener === 'function' +            )) { +                throw new Error('Event handler type not supported'); +            } + +            return new Promise((resolve, reject) => { +                const runtimeMessageCallback = ({action, params}, sender, sendResponse) => { +                    let timeoutId = null; +                    if (timeout !== null) { +                        timeoutId = window.setTimeout(() => { +                            timeoutId = null; +                            eventHandler.removeListener(runtimeMessageCallback); +                            reject(new Error(`Listener timed out in ${timeout} ms`)); +                        }, timeout); +                    } + +                    const cleanupResolve = (value) => { +                        if (timeoutId !== null) { +                            window.clearTimeout(timeoutId); +                            timeoutId = null; +                        } +                        eventHandler.removeListener(runtimeMessageCallback); +                        sendResponse(); +                        resolve(value); +                    }; + +                    userCallback({action, params}, {resolve: cleanupResolve, sender}); +                }; + +                eventHandler.addListener(runtimeMessageCallback); +            }); +        } +          // Private          _onMessage({action, params}, sender, callback) { @@ -299,10 +364,6 @@ const yomichan = (() => {              return false;          } -        _onBackendPrepared() { -            this._isBackendPreparedResolve(); -        } -          _onMessageGetUrl() {              return {url: window.location.href};          } diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index 49afc44b..f1122e3d 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -19,11 +19,13 @@  /* global   * TemplateHandler   * apiGetDisplayTemplatesHtml + * jp   */  class DisplayGenerator {      constructor() {          this._templateHandler = null; +        this._termPitchAccentStaticTemplateIsSetup = false;      }      async prepare() { @@ -36,17 +38,33 @@ class DisplayGenerator {          const expressionsContainer = node.querySelector('.term-expression-list');          const reasonsContainer = node.querySelector('.term-reasons'); +        const pitchesContainer = node.querySelector('.term-pitch-accent-group-list');          const frequenciesContainer = node.querySelector('.frequencies');          const definitionsContainer = node.querySelector('.term-definition-list');          const debugInfoContainer = node.querySelector('.debug-info'); +        const bodyContainer = node.querySelector('.term-entry-body'); + +        const pitches = DisplayGenerator._getPitchInfos(details); +        const pitchCount = pitches.reduce((i, v) => i + v[1].length, 0);          const expressionMulti = Array.isArray(details.expressions);          const definitionMulti = Array.isArray(details.definitions); +        const expressionCount = expressionMulti ? details.expressions.length : 1; +        const definitionCount = definitionMulti ? details.definitions.length : 1; +        const uniqueExpressionCount = Array.isArray(details.expression) ? new Set(details.expression).size : 1;          node.dataset.expressionMulti = `${expressionMulti}`;          node.dataset.definitionMulti = `${definitionMulti}`; -        node.dataset.expressionCount = `${expressionMulti ? details.expressions.length : 1}`; -        node.dataset.definitionCount = `${definitionMulti ? details.definitions.length : 1}`; +        node.dataset.expressionCount = `${expressionCount}`; +        node.dataset.definitionCount = `${definitionCount}`; +        node.dataset.uniqueExpressionCount = `${uniqueExpressionCount}`; +        node.dataset.pitchAccentDictionaryCount = `${pitches.length}`; +        node.dataset.pitchAccentCount = `${pitchCount}`; + +        bodyContainer.dataset.sectionCount = `${ +            (definitionCount > 0 ? 1 : 0) + +            (pitches.length > 0 ? 1 : 0) +        }`;          const termTags = details.termTags;          let expressions = details.expressions; @@ -55,6 +73,7 @@ class DisplayGenerator {          DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), expressions, [[details, termTags]]);          DisplayGenerator._appendMultiple(reasonsContainer, this.createTermReason.bind(this), details.reasons);          DisplayGenerator._appendMultiple(frequenciesContainer, this.createFrequencyTag.bind(this), details.frequencies); +        DisplayGenerator._appendMultiple(pitchesContainer, this.createPitches.bind(this), pitches);          DisplayGenerator._appendMultiple(definitionsContainer, this.createTermDefinitionItem.bind(this), details.definitions, [details]);          if (debugInfoContainer !== null) { @@ -261,6 +280,133 @@ class DisplayGenerator {          return node;      } +    createPitches(details) { +        if (!this._termPitchAccentStaticTemplateIsSetup) { +            this._termPitchAccentStaticTemplateIsSetup = true; +            const t = this._templateHandler.instantiate('term-pitch-accent-static'); +            document.head.appendChild(t); +        } + +        const [dictionary, dictionaryPitches] = details; + +        const node = this._templateHandler.instantiate('term-pitch-accent-group'); +        node.dataset.dictionary = dictionary; +        node.dataset.pitchesMulti = 'true'; +        node.dataset.pitchesCount = `${dictionaryPitches.length}`; + +        const tag = this.createTag({notes: '', name: dictionary, category: 'pitch-accent-dictionary'}); +        node.querySelector('.term-pitch-accent-group-tag-list').appendChild(tag); + +        const n = node.querySelector('.term-pitch-accent-list'); +        DisplayGenerator._appendMultiple(n, this.createPitch.bind(this), dictionaryPitches); + +        return node; +    } + +    createPitch(details) { +        const {reading, position, tags, exclusiveExpressions, exclusiveReadings} = details; +        const morae = jp.getKanaMorae(reading); + +        const node = this._templateHandler.instantiate('term-pitch-accent'); + +        node.dataset.pitchAccentPosition = `${position}`; +        node.dataset.tagCount = `${tags.length}`; + +        let n = node.querySelector('.term-pitch-accent-position'); +        n.textContent = `${position}`; + +        n = node.querySelector('.term-pitch-accent-tag-list'); +        DisplayGenerator._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._templateHandler.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'; +            n2.textContent = mora; + +            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._templateHandler.instantiate(templateName); +            node.dataset.type = 'expression'; +            node.textContent = exclusiveExpression; +            container.appendChild(node); +        } + +        for (const exclusiveReading of exclusiveReadings) { +            const node = this._templateHandler.instantiate(templateName); +            node.dataset.type = 'reading'; +            node.textContent = exclusiveReading; +            container.appendChild(node); +        } + +        container.dataset.multi = 'true'; +        container.dataset.count = `${exclusiveExpressions.length + exclusiveReadings.length}`; +        container.dataset.expressionCount = `${exclusiveExpressions.length}`; +        container.dataset.readingCount = `${exclusiveReadings.length}`; +    } + +    populatePitchGraph(svg, position, morae) { +        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')}`); +    } +      createFrequencyTag(details) {          const node = this._templateHandler.instantiate('tag-frequency'); @@ -283,7 +429,7 @@ class DisplayGenerator {      _appendKanjiLinks(container, text) {          let part = '';          for (const c of text) { -            if (DisplayGenerator._isCharacterKanji(c)) { +            if (jp.isCodePointKanji(c.codePointAt(0))) {                  if (part.length > 0) {                      container.appendChild(document.createTextNode(part));                      part = ''; @@ -300,30 +446,28 @@ class DisplayGenerator {          }      } -    static _isCharacterKanji(c) { -        const code = c.codePointAt(0); -        return ( -            code >= 0x4e00 && code < 0x9fb0 || -            code >= 0x3400 && code < 0x4dc0 -        ); -    } - -    static _appendMultiple(container, createItem, detailsArray, fallback=[]) { +    static _appendMultiple(container, createItem, detailsIterable, fallback=[]) {          if (container === null) { return 0; } -        const isArray = Array.isArray(detailsArray); -        if (!isArray) { detailsArray = fallback; } - -        container.dataset.multi = `${isArray}`; -        container.dataset.count = `${detailsArray.length}`; +        const multi = ( +            detailsIterable !== null && +            typeof detailsIterable === 'object' && +            typeof detailsIterable[Symbol.iterator] !== 'undefined' +        ); +        if (!multi) { detailsIterable = fallback; } -        for (const details of detailsArray) { +        let count = 0; +        for (const details of detailsIterable) {              const item = createItem(details);              if (item === null) { continue; }              container.appendChild(item); +            ++count;          } -        return detailsArray.length; +        container.dataset.multi = `${multi}`; +        container.dataset.count = `${count}`; + +        return count;      }      static _appendFurigana(container, segments, addText) { @@ -349,4 +493,79 @@ class DisplayGenerator {              container.appendChild(document.createTextNode(parts[i]));          }      } + +    static _getPitchInfos(definition) { +        const results = new Map(); + +        const allExpressions = new Set(); +        const allReadings = new Set(); +        const expressions = definition.expressions; +        const sources = Array.isArray(expressions) ? expressions : [definition]; +        for (const {pitches: expressionPitches, expression} of sources) { +            allExpressions.add(expression); +            for (const {reading, pitches, dictionary} of expressionPitches) { +                allReadings.add(reading); +                let dictionaryResults = results.get(dictionary); +                if (typeof dictionaryResults === 'undefined') { +                    dictionaryResults = []; +                    results.set(dictionary, dictionaryResults); +                } + +                for (const {position, tags} of pitches) { +                    let pitchInfo = DisplayGenerator._findExistingPitchInfo(reading, position, tags, dictionaryResults); +                    if (pitchInfo === null) { +                        pitchInfo = {expressions: new Set(), reading, position, tags}; +                        dictionaryResults.push(pitchInfo); +                    } +                    pitchInfo.expressions.add(expression); +                } +            } +        } + +        for (const dictionaryResults of results.values()) { +            for (const result of dictionaryResults) { +                const exclusiveExpressions = []; +                const exclusiveReadings = []; +                const resultExpressions = result.expressions; +                if (!areSetsEqual(resultExpressions, allExpressions)) { +                    exclusiveExpressions.push(...getSetIntersection(resultExpressions, allExpressions)); +                } +                if (allReadings.size > 1) { +                    exclusiveReadings.push(result.reading); +                } +                result.exclusiveExpressions = exclusiveExpressions; +                result.exclusiveReadings = exclusiveReadings; +            } +        } + +        return [...results.entries()]; +    } + +    static _findExistingPitchInfo(reading, position, tags, pitchInfoList) { +        for (const pitchInfo of pitchInfoList) { +            if ( +                pitchInfo.reading === reading && +                pitchInfo.position === position && +                DisplayGenerator._areTagListsEqual(pitchInfo.tags, tags) +            ) { +                return pitchInfo; +            } +        } +        return null; +    } + +    static _areTagListsEqual(tagList1, tagList2) { +        const ii = tagList1.length; +        if (tagList2.length !== ii) { return false; } + +        for (let i = 0; i < ii; ++i) { +            const tag1 = tagList1[i]; +            const tag2 = tagList2[i]; +            if (tag1.name !== tag2.name || tag1.dictionary !== tag2.dictionary) { +                return false; +            } +        } + +        return true; +    }  } diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 515e28a7..2f456c3e 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -40,6 +40,7 @@ class Display {          this.spinner = spinner;          this.container = container;          this.definitions = []; +        this.optionsContext = null;          this.options = null;          this.context = null;          this.index = 0; @@ -165,12 +166,11 @@ class Display {          this.setInteractive(true);      } -    async prepare(options=null) { +    async prepare() {          await yomichan.prepare(); -        const displayGeneratorPromise = this.displayGenerator.prepare(); -        const updateOptionsPromise = this.updateOptions(options); -        await Promise.all([displayGeneratorPromise, updateOptionsPromise]); -        yomichan.on('optionsUpdated', () => this.updateOptions(null)); +        await this.displayGenerator.prepare(); +        await this.updateOptions(); +        yomichan.on('optionsUpdated', () => this.updateOptions());      }      onError(_error) { @@ -369,11 +369,11 @@ class Display {      }      getOptionsContext() { -        throw new Error('Override me'); +        return this.optionsContext;      } -    async updateOptions(options) { -        this.options = options ? options : await apiOptionsGet(this.getOptionsContext()); +    async updateOptions() { +        this.options = await apiOptionsGet(this.getOptionsContext());          this.updateDocumentOptions(this.options);          this.updateTheme(this.options.general.popupTheme);          this.setCustomCss(this.options.general.customPopupCss); @@ -385,6 +385,9 @@ class Display {          data.audioEnabled = `${options.audio.enabled}`;          data.compactGlossaries = `${options.general.compactGlossaries}`;          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}`;      } @@ -749,15 +752,16 @@ class Display {          try {              this.setSpinnerVisible(true); -            const context = {}; +            const details = {};              if (this.noteUsesScreenshot(mode)) {                  const screenshot = await this.getScreenshot();                  if (screenshot) { -                    context.screenshot = screenshot; +                    details.screenshot = screenshot;                  }              } -            const noteId = await apiDefinitionAdd(definition, mode, context, this.getOptionsContext()); +            const context = await this._getNoteContext(); +            const noteId = await apiDefinitionAdd(definition, mode, context, details, this.getOptionsContext());              if (noteId) {                  const index = this.definitions.indexOf(definition);                  const adderButton = this.adderButtonFind(index, mode); @@ -905,12 +909,17 @@ class Display {      async getDefinitionsAddable(definitions, modes) {          try { -            return await apiDefinitionsAddable(definitions, modes, this.getOptionsContext()); +            const context = await this._getNoteContext(); +            return await apiDefinitionsAddable(definitions, modes, context, this.getOptionsContext());          } catch (e) {              return [];          }      } +    async getDocumentTitle() { +        return document.title; +    } +      static indexOf(nodeList, node) {          for (let i = 0, ii = nodeList.length; i < ii; ++i) {              if (nodeList[i] === node) { @@ -931,6 +940,15 @@ class Display {          return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : '');      } +    async _getNoteContext() { +        const documentTitle = await this.getDocumentTitle(); +        return { +            document: { +                title: documentTitle +            } +        }; +    } +      async _getAudioUri(definition, source) {          const optionsContext = this.getOptionsContext();          return await apiAudioGetUri(definition, source, optionsContext); diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js new file mode 100644 index 00000000..e6b9a8a0 --- /dev/null +++ b/ext/mixed/js/japanese.js @@ -0,0 +1,148 @@ +/* + * Copyright (C) 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 <https://www.gnu.org/licenses/>. + */ + +const jp = (() => { +    const HIRAGANA_RANGE = [0x3040, 0x309f]; +    const KATAKANA_RANGE = [0x30a0, 0x30ff]; +    const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE]; + +    const CJK_UNIFIED_IDEOGRAPHS_RANGE = [0x4e00, 0x9fff]; +    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE = [0x3400, 0x4dbf]; +    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE = [0x20000, 0x2a6df]; +    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE = [0x2a700, 0x2b73f]; +    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE = [0x2b740, 0x2b81f]; +    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE = [0x2b820, 0x2ceaf]; +    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE = [0x2ceb0, 0x2ebef]; +    const CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE = [0x2f800, 0x2fa1f]; +    const CJK_UNIFIED_IDEOGRAPHS_RANGES = [ +        CJK_UNIFIED_IDEOGRAPHS_RANGE, +        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE, +        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE, +        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE, +        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE, +        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE, +        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE, +        CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE +    ]; + +    // Japanese character ranges, roughly ordered in order of expected frequency +    const JAPANESE_RANGES = [ +        HIRAGANA_RANGE, +        KATAKANA_RANGE, + +        ...CJK_UNIFIED_IDEOGRAPHS_RANGES, + +        [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 +    ]; + +    const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ')); + + +    // Character code testing functions + +    function isCodePointKanji(codePoint) { +        return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES); +    } + +    function isCodePointKana(codePoint) { +        return isCodePointInRanges(codePoint, KANA_RANGES); +    } + +    function isCodePointJapanese(codePoint) { +        return isCodePointInRanges(codePoint, JAPANESE_RANGES); +    } + +    function isCodePointInRanges(codePoint, ranges) { +        for (const [min, max] of ranges) { +            if (codePoint >= min && codePoint <= max) { +                return true; +            } +        } +        return false; +    } + + +    // String testing functions + +    function isStringEntirelyKana(str) { +        if (str.length === 0) { return false; } +        for (const c of str) { +            if (!isCodePointKana(c.codePointAt(0))) { +                return false; +            } +        } +        return true; +    } + +    function isStringPartiallyJapanese(str) { +        if (str.length === 0) { return false; } +        for (const c of str) { +            if (isCodePointJapanese(c.codePointAt(0))) { +                return true; +            } +        } +        return false; +    } + + +    // Mora functions + +    function isMoraPitchHigh(moraIndex, pitchAccentPosition) { +        return pitchAccentPosition === 0 ? (moraIndex > 0) : (moraIndex < pitchAccentPosition); +    } + +    function getKanaMorae(text) { +        const morae = []; +        let i; +        for (const c of text) { +            if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) { +                morae[i - 1] += c; +            } else { +                morae.push(c); +            } +        } +        return morae; +    } + + +    // Exports + +    return { +        isCodePointKanji, +        isCodePointKana, +        isCodePointJapanese, +        isStringEntirelyKana, +        isStringPartiallyJapanese, +        isMoraPitchHigh, +        getKanaMorae +    }; +})(); diff --git a/ext/mixed/js/object-property-accessor.js b/ext/mixed/js/object-property-accessor.js new file mode 100644 index 00000000..108afc0d --- /dev/null +++ b/ext/mixed/js/object-property-accessor.js @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2016-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 <https://www.gnu.org/licenses/>. + */ + +/** + * Class used to get and set generic properties of an object by using path strings. + */ +class ObjectPropertyAccessor { +    constructor(target, setter=null) { +        this._target = target; +        this._setter = (typeof setter === 'function' ? setter : null); +    } + +    getProperty(pathArray, pathLength) { +        let target = this._target; +        const ii = typeof pathLength === 'number' ? Math.min(pathArray.length, pathLength) : pathArray.length; +        for (let i = 0; i < ii; ++i) { +            const key = pathArray[i]; +            if (!ObjectPropertyAccessor.hasProperty(target, key)) { +                throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray.slice(0, i + 1))}`); +            } +            target = target[key]; +        } +        return target; +    } + +    setProperty(pathArray, value) { +        if (pathArray.length === 0) { +            throw new Error('Invalid path'); +        } + +        const target = this.getProperty(pathArray, pathArray.length - 1); +        const key = pathArray[pathArray.length - 1]; +        if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) { +            throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); +        } + +        if (this._setter !== null) { +            this._setter(target, key, value, pathArray); +        } else { +            target[key] = value; +        } +    } + +    static getPathString(pathArray) { +        const regexShort = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +        let pathString = ''; +        let first = true; +        for (let part of pathArray) { +            switch (typeof part) { +                case 'number': +                    if (Math.floor(part) !== part || part < 0) { +                        throw new Error('Invalid index'); +                    } +                    part = `[${part}]`; +                    break; +                case 'string': +                    if (!regexShort.test(part)) { +                        const escapedPart = part.replace(/["\\]/g, '\\$&'); +                        part = `["${escapedPart}"]`; +                    } else { +                        if (!first) { +                            part = `.${part}`; +                        } +                    } +                    break; +                default: +                    throw new Error(`Invalid type: ${typeof part}`); +            } +            pathString += part; +            first = false; +        } +        return pathString; +    } + +    static getPathArray(pathString) { +        const pathArray = []; +        let state = 'empty'; +        let quote = 0; +        let value = ''; +        let escaped = false; +        for (const c of pathString) { +            const v = c.codePointAt(0); +            switch (state) { +                case 'empty': // Empty +                case 'id-start': // Expecting identifier start +                    if (v === 0x5b) { // '[' +                        if (state === 'id-start') { +                            throw new Error(`Unexpected character: ${c}`); +                        } +                        state = 'open-bracket'; +                    } else if ( +                        (v >= 0x41 && v <= 0x5a) || // ['A', 'Z'] +                        (v >= 0x61 && v <= 0x7a) || // ['a', 'z'] +                        v === 0x5f // '_' +                    ) { +                        state = 'id'; +                        value += c; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'id': // Identifier +                    if ( +                        (v >= 0x41 && v <= 0x5a) || // ['A', 'Z'] +                        (v >= 0x61 && v <= 0x7a) || // ['a', 'z'] +                        (v >= 0x30 && v <= 0x39) || // ['0', '9'] +                        v === 0x5f // '_' +                    ) { +                        value += c; +                    } else if (v === 0x5b) { // '[' +                        pathArray.push(value); +                        value = ''; +                        state = 'open-bracket'; +                    } else if (v === 0x2e) { // '.' +                        pathArray.push(value); +                        value = ''; +                        state = 'id-start'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'open-bracket': // Open bracket +                    if (v === 0x22 || v === 0x27) { // '"' or '\'' +                        quote = v; +                        state = 'string'; +                    } else if (v >= 0x30 && v <= 0x39) { // ['0', '9'] +                        state = 'number'; +                        value += c; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'string': // Quoted string +                    if (escaped) { +                        value += c; +                        escaped = false; +                    } else if (v === 0x5c) { // '\\' +                        escaped = true; +                    } else if (v !== quote) { +                        value += c; +                    } else { +                        state = 'close-bracket'; +                    } +                    break; +                case 'number': // Number +                    if (v >= 0x30 && v <= 0x39) { // ['0', '9'] +                        value += c; +                    } else if (v === 0x5d) { // ']' +                        pathArray.push(Number.parseInt(value, 10)); +                        value = ''; +                        state = 'next'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'close-bracket': // Expecting closing bracket after quoted string +                    if (v === 0x5d) { // ']' +                        pathArray.push(value); +                        value = ''; +                        state = 'next'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'next': // Expecting . or [ +                    if (v === 0x5b) { // '[' +                        state = 'open-bracket'; +                    } else if (v === 0x2e) { // '.' +                        state = 'id-start'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +            } +        } +        switch (state) { +            case 'empty': +            case 'next': +                break; +            case 'id': +                pathArray.push(value); +                value = ''; +                break; +            default: +                throw new Error('Path not terminated correctly'); +        } +        return pathArray; +    } + +    static hasProperty(object, property) { +        switch (typeof property) { +            case 'string': +                return ( +                    typeof object === 'object' && +                    object !== null && +                    !Array.isArray(object) && +                    Object.prototype.hasOwnProperty.call(object, property) +                ); +            case 'number': +                return ( +                    Array.isArray(object) && +                    property >= 0 && +                    property < object.length && +                    property === Math.floor(property) +                ); +            default: +                return false; +        } +    } + +    static isValidPropertyType(object, property) { +        switch (typeof property) { +            case 'string': +                return ( +                    typeof object === 'object' && +                    object !== null && +                    !Array.isArray(object) +                ); +            case 'number': +                return ( +                    Array.isArray(object) && +                    property >= 0 && +                    property === Math.floor(property) +                ); +            default: +                return false; +        } +    } +} diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index a08e09fb..b8156c01 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -23,13 +23,15 @@   */  class TextScanner { -    constructor(node, ignoreNodes, ignoreElements, ignorePoints) { +    constructor(node, ignoreElements, ignorePoints) {          this.node = node; -        this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null);          this.ignoreElements = ignoreElements;          this.ignorePoints = ignorePoints; +        this.ignoreNodes = null; +          this.scanTimerPromise = null; +        this.causeCurrent = null;          this.textSourceCurrent = null;          this.pendingLookup = false;          this.options = null; @@ -298,6 +300,7 @@ class TextScanner {                  this.pendingLookup = true;                  const result = await this.onSearchSource(textSource, cause);                  if (result !== null) { +                    this.causeCurrent = cause;                      this.textSourceCurrent = textSource;                      if (this.options.scanning.selectText) {                          textSource.select(); |