diff options
Diffstat (limited to 'ext/js/language')
-rw-r--r-- | ext/js/language/dictionary-data-util.js | 217 | ||||
-rw-r--r-- | ext/js/language/japanese-util.js | 610 | ||||
-rw-r--r-- | ext/js/language/text-scanner.js | 982 |
3 files changed, 1809 insertions, 0 deletions
diff --git a/ext/js/language/dictionary-data-util.js b/ext/js/language/dictionary-data-util.js new file mode 100644 index 00000000..70a51e89 --- /dev/null +++ b/ext/js/language/dictionary-data-util.js @@ -0,0 +1,217 @@ +/* + * 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 DictionaryDataUtil { + static groupTermTags(definition) { + const {expressions} = definition; + const expressionsLength = expressions.length; + const uniqueCheck = (expressionsLength > 1); + const resultsMap = new Map(); + const results = []; + for (let i = 0; i < expressionsLength; ++i) { + const {termTags, expression, reading} = expressions[i]; + for (const tag of termTags) { + if (uniqueCheck) { + const {name, category, notes, dictionary} = tag; + const key = this._createMapKey([name, category, notes, dictionary]); + const index = resultsMap.get(key); + if (typeof index !== 'undefined') { + const existingItem = results[index]; + existingItem.expressions.push({index: i, expression, reading}); + continue; + } + resultsMap.set(key, results.length); + } + + const item = { + tag, + expressions: [{index: i, expression, reading}] + }; + results.push(item); + } + } + return results; + } + + static groupTermFrequencies(frequencies) { + const map1 = new Map(); + for (const {dictionary, expression, reading, hasReading, frequency} of frequencies) { + let map2 = map1.get(dictionary); + if (typeof map2 === 'undefined') { + map2 = new Map(); + map1.set(dictionary, map2); + } + + const readingKey = hasReading ? reading : null; + const key = this._createMapKey([expression, readingKey]); + let frequencyData = map2.get(key); + if (typeof frequencyData === 'undefined') { + frequencyData = {expression, reading: readingKey, frequencies: new Set()}; + map2.set(key, frequencyData); + } + + frequencyData.frequencies.add(frequency); + } + return this._createFrequencyGroupsFromMap(map1); + } + + static groupKanjiFrequencies(frequencies) { + const map1 = new Map(); + for (const {dictionary, character, frequency} of frequencies) { + let map2 = map1.get(dictionary); + if (typeof map2 === 'undefined') { + map2 = new Map(); + map1.set(dictionary, map2); + } + + let frequencyData = map2.get(character); + if (typeof frequencyData === 'undefined') { + frequencyData = {character, frequencies: new Set()}; + map2.set(character, frequencyData); + } + + frequencyData.frequencies.add(frequency); + } + return this._createFrequencyGroupsFromMap(map1); + } + + static getPitchAccentInfos(definition) { + if (definition.type === 'kanji') { return []; } + + const results = new Map(); + const allExpressions = new Set(); + const allReadings = new Set(); + + for (let {expression, reading, pitches: expressionPitches} of definition.expressions) { + if (reading.length === 0) { reading = expression; } + allExpressions.add(expression); + allReadings.add(reading); + + for (const {pitches, dictionary} of expressionPitches) { + let dictionaryResults = results.get(dictionary); + if (typeof dictionaryResults === 'undefined') { + dictionaryResults = []; + results.set(dictionary, dictionaryResults); + } + + for (const {position, tags} of pitches) { + let pitchAccentInfo = this._findExistingPitchAccentInfo(reading, position, tags, dictionaryResults); + if (pitchAccentInfo === null) { + pitchAccentInfo = {expressions: new Set(), reading, position, tags}; + dictionaryResults.push(pitchAccentInfo); + } + pitchAccentInfo.expressions.add(expression); + } + } + } + + const multipleReadings = (allReadings.size > 1); + for (const dictionaryResults of results.values()) { + for (const result of dictionaryResults) { + const exclusiveExpressions = []; + const exclusiveReadings = []; + const resultExpressions = result.expressions; + if (!this._areSetsEqual(resultExpressions, allExpressions)) { + exclusiveExpressions.push(...this._getSetIntersection(resultExpressions, allExpressions)); + } + if (multipleReadings) { + exclusiveReadings.push(result.reading); + } + result.expressions = [...resultExpressions]; + result.exclusiveExpressions = exclusiveExpressions; + result.exclusiveReadings = exclusiveReadings; + } + } + + const results2 = []; + for (const [dictionary, pitches] of results.entries()) { + results2.push({dictionary, pitches}); + } + return results2; + } + + // Private + + static _createFrequencyGroupsFromMap(map) { + const results = []; + for (const [dictionary, map2] of map.entries()) { + const frequencyDataArray = []; + for (const frequencyData of map2.values()) { + frequencyData.frequencies = [...frequencyData.frequencies]; + frequencyDataArray.push(frequencyData); + } + results.push({dictionary, frequencyData: frequencyDataArray}); + } + return results; + } + + static _findExistingPitchAccentInfo(reading, position, tags, pitchAccentInfoList) { + for (const pitchInfo of pitchAccentInfoList) { + if ( + pitchInfo.reading === reading && + pitchInfo.position === position && + this._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; + } + + static _areSetsEqual(set1, set2) { + if (set1.size !== set2.size) { + return false; + } + + for (const value of set1) { + if (!set2.has(value)) { + return false; + } + } + + return true; + } + + static _getSetIntersection(set1, set2) { + const result = []; + for (const value of set1) { + if (set2.has(value)) { + result.push(value); + } + } + return result; + } + + static _createMapKey(array) { + return JSON.stringify(array); + } +} diff --git a/ext/js/language/japanese-util.js b/ext/js/language/japanese-util.js new file mode 100644 index 00000000..c2ce9627 --- /dev/null +++ b/ext/js/language/japanese-util.js @@ -0,0 +1,610 @@ +/* + * 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/>. + */ + +const JapaneseUtil = (() => { + const ITERATION_MARK_CODE_POINT = 0x3005; + const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063; + const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3; + const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5; + const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6; + const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc; + + const HIRAGANA_RANGE = [0x3040, 0x309f]; + const KATAKANA_RANGE = [0x30a0, 0x30ff]; + + const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096]; + const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6]; + + 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('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ')); + + const HALFWIDTH_KATAKANA_MAPPING = new Map([ + ['ヲ', 'ヲヺ-'], + ['ァ', 'ァ--'], + ['ィ', 'ィ--'], + ['ゥ', 'ゥ--'], + ['ェ', 'ェ--'], + ['ォ', 'ォ--'], + ['ャ', 'ャ--'], + ['ュ', 'ュ--'], + ['ョ', 'ョ--'], + ['ッ', 'ッ--'], + ['ー', 'ー--'], + ['ア', 'ア--'], + ['イ', 'イ--'], + ['ウ', 'ウヴ-'], + ['エ', 'エ--'], + ['オ', 'オ--'], + ['カ', 'カガ-'], + ['キ', 'キギ-'], + ['ク', 'クグ-'], + ['ケ', 'ケゲ-'], + ['コ', 'コゴ-'], + ['サ', 'サザ-'], + ['シ', 'シジ-'], + ['ス', 'スズ-'], + ['セ', 'セゼ-'], + ['ソ', 'ソゾ-'], + ['タ', 'タダ-'], + ['チ', 'チヂ-'], + ['ツ', 'ツヅ-'], + ['テ', 'テデ-'], + ['ト', 'トド-'], + ['ナ', 'ナ--'], + ['ニ', 'ニ--'], + ['ヌ', 'ヌ--'], + ['ネ', 'ネ--'], + ['ノ', 'ノ--'], + ['ハ', 'ハバパ'], + ['ヒ', 'ヒビピ'], + ['フ', 'フブプ'], + ['ヘ', 'ヘベペ'], + ['ホ', 'ホボポ'], + ['マ', 'マ--'], + ['ミ', 'ミ--'], + ['ム', 'ム--'], + ['メ', 'メ--'], + ['モ', 'モ--'], + ['ヤ', 'ヤ--'], + ['ユ', 'ユ--'], + ['ヨ', 'ヨ--'], + ['ラ', 'ラ--'], + ['リ', 'リ--'], + ['ル', 'ル--'], + ['レ', 'レ--'], + ['ロ', 'ロ--'], + ['ワ', 'ワ--'], + ['ン', 'ン--'] + ]); + + const VOWEL_TO_KANA_MAPPING = new Map([ + ['a', 'ぁあかがさざただなはばぱまゃやらゎわヵァアカガサザタダナハバパマャヤラヮワヵヷ'], + ['i', 'ぃいきぎしじちぢにひびぴみりゐィイキギシジチヂニヒビピミリヰヸ'], + ['u', 'ぅうくぐすずっつづぬふぶぷむゅゆるゥウクグスズッツヅヌフブプムュユルヴ'], + ['e', 'ぇえけげせぜてでねへべぺめれゑヶェエケゲセゼテデネヘベペメレヱヶヹ'], + ['o', 'ぉおこごそぞとどのほぼぽもょよろをォオコゴソゾトドノホボポモョヨロヲヺ'], + ['', 'のノ'] + ]); + + const KANA_TO_VOWEL_MAPPING = (() => { + const map = new Map(); + for (const [vowel, characters] of VOWEL_TO_KANA_MAPPING) { + for (const character of characters) { + map.set(character, vowel); + } + } + return map; + })(); + + + function isCodePointInRange(codePoint, [min, max]) { + return (codePoint >= min && codePoint <= max); + } + + function isCodePointInRanges(codePoint, ranges) { + for (const [min, max] of ranges) { + if (codePoint >= min && codePoint <= max) { + return true; + } + } + return false; + } + + function getProlongedHiragana(previousCharacter) { + switch (KANA_TO_VOWEL_MAPPING.get(previousCharacter)) { + case 'a': return 'あ'; + case 'i': return 'い'; + case 'u': return 'う'; + case 'e': return 'え'; + case 'o': return 'う'; + default: return null; + } + } + + + // eslint-disable-next-line no-shadow + class JapaneseUtil { + constructor(wanakana=null) { + this._wanakana = wanakana; + } + + // Character code testing functions + + isCodePointKanji(codePoint) { + return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES); + } + + isCodePointKana(codePoint) { + return isCodePointInRanges(codePoint, KANA_RANGES); + } + + isCodePointJapanese(codePoint) { + return isCodePointInRanges(codePoint, JAPANESE_RANGES); + } + + // String testing functions + + isStringEntirelyKana(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (!isCodePointInRanges(c.codePointAt(0), KANA_RANGES)) { + return false; + } + } + return true; + } + + isStringPartiallyJapanese(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (isCodePointInRanges(c.codePointAt(0), JAPANESE_RANGES)) { + return true; + } + } + return false; + } + + // Mora functions + + isMoraPitchHigh(moraIndex, pitchAccentPosition) { + switch (pitchAccentPosition) { + case 0: return (moraIndex > 0); + case 1: return (moraIndex < 1); + default: return (moraIndex > 0 && moraIndex < pitchAccentPosition); + } + } + + 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; + } + + // Conversion functions + + convertToKana(text) { + return this._getWanakana().toKana(text); + } + + convertKatakanaToHiragana(text) { + let result = ''; + const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]); + for (let char of text) { + const codePoint = char.codePointAt(0); + if (codePoint === KATAKANA_SMALL_KA_CODE_POINT || codePoint === KATAKANA_SMALL_KE_CODE_POINT) { + // No change + } else if (codePoint === KANA_PROLONGED_SOUND_MARK_CODE_POINT) { + if (result.length > 0) { + const char2 = getProlongedHiragana(result[result.length - 1]); + if (char2 !== null) { char = char2; } + } + } else if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) { + char = String.fromCodePoint(codePoint + offset); + } + result += char; + } + return result; + } + + convertHiraganaToKatakana(text) { + let result = ''; + const offset = (KATAKANA_CONVERSION_RANGE[0] - HIRAGANA_CONVERSION_RANGE[0]); + for (let char of text) { + const codePoint = char.codePointAt(0); + if (isCodePointInRange(codePoint, HIRAGANA_CONVERSION_RANGE)) { + char = String.fromCodePoint(codePoint + offset); + } + result += char; + } + return result; + } + + convertToRomaji(text) { + const wanakana = this._getWanakana(); + return wanakana.toRomaji(text); + } + + convertReading(expression, reading, readingMode) { + switch (readingMode) { + case 'hiragana': + return this.convertKatakanaToHiragana(reading); + case 'katakana': + return this.convertHiraganaToKatakana(reading); + case 'romaji': + if (reading) { + return this.convertToRomaji(reading); + } else { + if (this.isStringEntirelyKana(expression)) { + return this.convertToRomaji(expression); + } + } + return reading; + case 'none': + return ''; + default: + return reading; + } + } + + convertNumericToFullWidth(text) { + let result = ''; + for (const char of text) { + let c = char.codePointAt(0); + if (c >= 0x30 && c <= 0x39) { // ['0', '9'] + c += 0xff10 - 0x30; // 0xff10 = '0' full width + result += String.fromCodePoint(c); + } else { + result += char; + } + } + return result; + } + + convertHalfWidthKanaToFullWidth(text, sourceMap=null) { + let result = ''; + + // This function is safe to use charCodeAt instead of codePointAt, since all + // the relevant characters are represented with a single UTF-16 character code. + for (let i = 0, ii = text.length; i < ii; ++i) { + const c = text[i]; + const mapping = 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 (sourceMap !== null && index > 0) { + sourceMap.combine(result.length, 1); + } + result += c2; + } + + return result; + } + + convertAlphabeticToKana(text, sourceMap=null) { + let part = ''; + let result = ''; + + for (const char of text) { + // Note: 0x61 is the character code for 'a' + let c = char.codePointAt(0); + 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 += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + part = ''; + } + result += char; + continue; + } + part += String.fromCodePoint(c); + } + + if (part.length > 0) { + result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + } + return result; + } + + // Furigana distribution + + distributeFurigana(expression, reading) { + if (!reading || reading === expression) { + // Same + return [this._createFuriganaSegment(expression, '')]; + } + + const groups = []; + let groupPre = null; + let isKanaPre = null; + for (const c of expression) { + const codePoint = c.codePointAt(0); + const isKana = !(this.isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT); + if (isKana === isKanaPre) { + groupPre.text += c; + } else { + groupPre = {isKana, text: c, textNormalized: null}; + groups.push(groupPre); + isKanaPre = isKana; + } + } + for (const group of groups) { + if (group.isKana) { + group.textNormalized = this.convertKatakanaToHiragana(group.text); + } + } + + const readingNormalized = this.convertKatakanaToHiragana(reading); + const segments = this._segmentizeFurigana(reading, readingNormalized, groups, 0); + if (segments !== null) { + return segments; + } + + // Fallback + return [this._createFuriganaSegment(expression, reading)]; + } + + distributeFuriganaInflected(expression, reading, source) { + let stemLength = 0; + const shortest = Math.min(source.length, expression.length); + const sourceHiragana = this.convertKatakanaToHiragana(source); + const expressionHiragana = this.convertKatakanaToHiragana(expression); + while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) { + ++stemLength; + } + const offset = source.length - stemLength; + + const stemExpression = source.substring(0, source.length - offset); + const stemReading = reading.substring( + 0, + offset === 0 ? reading.length : reading.length - expression.length + stemLength + ); + const result = this.distributeFurigana(stemExpression, stemReading); + + if (stemLength !== source.length) { + result.push(this._createFuriganaSegment(source.substring(stemLength), '')); + } + + return result; + } + + // Miscellaneous + + collapseEmphaticSequences(text, fullCollapse, sourceMap=null) { + let result = ''; + let collapseCodePoint = -1; + const hasSourceMap = (sourceMap !== null); + for (const char of text) { + const c = char.codePointAt(0); + if ( + c === HIRAGANA_SMALL_TSU_CODE_POINT || + c === KATAKANA_SMALL_TSU_CODE_POINT || + c === KANA_PROLONGED_SOUND_MARK_CODE_POINT + ) { + if (collapseCodePoint !== c) { + collapseCodePoint = c; + if (!fullCollapse) { + result += char; + continue; + } + } + } else { + collapseCodePoint = -1; + result += char; + continue; + } + + if (hasSourceMap) { + sourceMap.combine(Math.max(0, result.length - 1), 1); + } + } + return result; + } + + // Private + + _createFuriganaSegment(text, furigana) { + return {text, furigana}; + } + + _segmentizeFurigana(reading, readingNormalized, groups, groupsStart) { + const groupCount = groups.length - groupsStart; + if (groupCount <= 0) { + return []; + } + + const group = groups[groupsStart]; + const {isKana, text} = group; + const textLength = text.length; + if (isKana) { + const {textNormalized} = group; + if (readingNormalized.startsWith(textNormalized)) { + const segments = this._segmentizeFurigana( + reading.substring(textLength), + readingNormalized.substring(textLength), + groups, + groupsStart + 1 + ); + if (segments !== null) { + const furigana = reading.startsWith(text) ? '' : reading.substring(0, textLength); + segments.unshift(this._createFuriganaSegment(text, furigana)); + return segments; + } + } + return null; + } else { + let result = null; + for (let i = reading.length; i >= textLength; --i) { + const segments = this._segmentizeFurigana( + reading.substring(i), + readingNormalized.substring(i), + groups, + groupsStart + 1 + ); + if (segments !== null) { + if (result !== null) { + // More than one way to segmentize the tail; mark as ambiguous + return null; + } + const furigana = reading.substring(0, i); + segments.unshift(this._createFuriganaSegment(text, furigana)); + result = segments; + } + // There is only one way to segmentize the last non-kana group + if (groupCount === 1) { + break; + } + } + return result; + } + } + + _getWanakana() { + const wanakana = this._wanakana; + if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); } + return wanakana; + } + + _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) { + const wanakana = this._getWanakana(); + const result = wanakana.toHiragana(text); + + // Generate source mapping + if (sourceMap !== null) { + 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) { + sourceMap.combine(sourceMapStart, removals); + } + ++sourceMapStart; + + // Empty elements + const additions = resultPosNext - resultPos - 1; + for (let j = 0; j < additions; ++j) { + sourceMap.insert(sourceMapStart, 0); + ++sourceMapStart; + } + + i = iNext; + resultPos = resultPosNext; + } + } + + return result; + } + } + + + return JapaneseUtil; +})(); diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js new file mode 100644 index 00000000..7672b69d --- /dev/null +++ b/ext/js/language/text-scanner.js @@ -0,0 +1,982 @@ +/* + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * DocumentUtil + * api + */ + +class TextScanner extends EventDispatcher { + constructor({ + node, + documentUtil, + getSearchContext, + ignoreElements=null, + ignorePoint=null, + searchTerms=false, + searchKanji=false, + searchOnClick=false, + searchOnClickOnly=false + }) { + super(); + this._node = node; + this._documentUtil = documentUtil; + this._getSearchContext = getSearchContext; + this._ignoreElements = ignoreElements; + this._ignorePoint = ignorePoint; + this._searchTerms = searchTerms; + this._searchKanji = searchKanji; + this._searchOnClick = searchOnClick; + this._searchOnClickOnly = searchOnClickOnly; + + this._isPrepared = false; + this._includeSelector = null; + this._excludeSelector = null; + + this._inputInfoCurrent = null; + this._scanTimerPromise = null; + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + this._pendingLookup = false; + + this._deepContentScan = false; + this._selectText = false; + this._delay = 0; + this._touchInputEnabled = false; + this._pointerEventsEnabled = false; + this._scanLength = 1; + this._layoutAwareScan = false; + this._preventMiddleMouse = false; + this._sentenceScanExtent = 0; + this._sentenceTerminatorMap = new Map(); + this._sentenceForwardQuoteMap = new Map(); + this._sentenceBackwardQuoteMap = new Map(); + this._inputs = []; + + this._enabled = false; + this._enabledValue = false; + this._eventListeners = new EventListenerCollection(); + + this._primaryTouchIdentifier = null; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + this._preventScroll = false; + this._penPointerPressed = false; + this._penPointerReleased = false; + this._pointerIdTypeMap = new Map(); + + this._canClearSelection = true; + } + + get canClearSelection() { + return this._canClearSelection; + } + + set canClearSelection(value) { + this._canClearSelection = value; + } + + get includeSelector() { + return this._includeSelector; + } + + set includeSelector(value) { + this._includeSelector = value; + } + + get excludeSelector() { + return this._excludeSelector; + } + + set excludeSelector(value) { + this._excludeSelector = value; + } + + prepare() { + this._isPrepared = true; + this.setEnabled(this._enabled); + } + + setEnabled(enabled) { + this._enabled = enabled; + + const value = enabled && this._isPrepared; + if (this._enabledValue === value) { return; } + + this._eventListeners.removeAllEventListeners(); + this._primaryTouchIdentifier = null; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + this._preventScroll = false; + this._penPointerPressed = false; + this._penPointerReleased = false; + this._pointerIdTypeMap.clear(); + + this._enabledValue = value; + + if (value) { + this._hookEvents(); + } else { + this.clearSelection(true); + } + } + + setOptions({ + inputs, + deepContentScan, + selectText, + delay, + touchInputEnabled, + pointerEventsEnabled, + scanLength, + layoutAwareScan, + preventMiddleMouse, + sentenceParsingOptions + }) { + if (Array.isArray(inputs)) { + this._inputs = inputs.map(({ + include, + exclude, + types, + options: { + searchTerms, + searchKanji, + scanOnTouchMove, + scanOnPenHover, + scanOnPenPress, + scanOnPenRelease, + preventTouchScrolling + } + }) => ({ + include: this._getInputArray(include), + exclude: this._getInputArray(exclude), + types: this._getInputTypeSet(types), + options: { + searchTerms, + searchKanji, + scanOnTouchMove, + scanOnPenHover, + scanOnPenPress, + scanOnPenRelease, + preventTouchScrolling + } + })); + } + if (typeof deepContentScan === 'boolean') { + this._deepContentScan = deepContentScan; + } + if (typeof selectText === 'boolean') { + this._selectText = selectText; + } + if (typeof delay === 'number') { + this._delay = delay; + } + if (typeof touchInputEnabled === 'boolean') { + this._touchInputEnabled = touchInputEnabled; + } + if (typeof pointerEventsEnabled === 'boolean') { + this._pointerEventsEnabled = pointerEventsEnabled; + } + if (typeof scanLength === 'number') { + this._scanLength = scanLength; + } + if (typeof layoutAwareScan === 'boolean') { + this._layoutAwareScan = layoutAwareScan; + } + if (typeof preventMiddleMouse === 'boolean') { + this._preventMiddleMouse = preventMiddleMouse; + } + if (typeof sentenceParsingOptions === 'object' && sentenceParsingOptions !== null) { + const {scanExtent, enableTerminationCharacters, terminationCharacters} = sentenceParsingOptions; + const hasTerminationCharacters = (typeof terminationCharacters === 'object' && Array.isArray(terminationCharacters)); + if (typeof scanExtent === 'number') { + this._sentenceScanExtent = sentenceParsingOptions.scanExtent; + } + if (typeof enableTerminationCharacters === 'boolean' || hasTerminationCharacters) { + const sentenceTerminatorMap = this._sentenceTerminatorMap; + const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; + const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; + sentenceTerminatorMap.clear(); + sentenceForwardQuoteMap.clear(); + sentenceBackwardQuoteMap.clear(); + if (enableTerminationCharacters !== false && hasTerminationCharacters) { + for (const {enabled, character1, character2, includeCharacterAtStart, includeCharacterAtEnd} of terminationCharacters) { + if (!enabled) { continue; } + if (character2 === null) { + sentenceTerminatorMap.set(character1, [includeCharacterAtStart, includeCharacterAtEnd]); + } else { + sentenceForwardQuoteMap.set(character1, [character2, includeCharacterAtStart]); + sentenceBackwardQuoteMap.set(character2, [character1, includeCharacterAtEnd]); + } + } + } + } + } + } + + getTextSourceContent(textSource, length, layoutAwareScan) { + const clonedTextSource = textSource.clone(); + + clonedTextSource.setEndOffset(length, layoutAwareScan); + + const includeSelector = this._includeSelector; + const excludeSelector = this._excludeSelector; + if (includeSelector !== null || excludeSelector !== null) { + this._constrainTextSource(clonedTextSource, includeSelector, excludeSelector, layoutAwareScan); + } + + return clonedTextSource.text(); + } + + hasSelection() { + return (this._textSourceCurrent !== null); + } + + clearSelection(passive) { + if (!this._canClearSelection) { return; } + if (this._textSourceCurrent !== null) { + if (this._textSourceCurrentSelected) { + this._textSourceCurrent.deselect(); + } + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + this._inputInfoCurrent = null; + } + this.trigger('clearSelection', {passive}); + } + + getCurrentTextSource() { + return this._textSourceCurrent; + } + + setCurrentTextSource(textSource) { + this._textSourceCurrent = textSource; + if (this._selectText) { + this._textSourceCurrent.select(); + this._textSourceCurrentSelected = true; + } else { + this._textSourceCurrentSelected = false; + } + } + + async searchLast() { + if (this._textSourceCurrent !== null && this._inputInfoCurrent !== null) { + await this._search(this._textSourceCurrent, this._searchTerms, this._searchKanji, this._inputInfoCurrent); + return true; + } + return false; + } + + async search(textSource, inputDetail) { + const inputInfo = this._createInputInfo(null, 'script', 'script', true, [], [], inputDetail); + return await this._search(textSource, this._searchTerms, this._searchKanji, inputInfo); + } + + // Private + + _createOptionsContextForInput(baseOptionsContext, inputInfo) { + const optionsContext = clone(baseOptionsContext); + const {modifiers, modifierKeys} = inputInfo; + optionsContext.modifiers = [...modifiers]; + optionsContext.modifierKeys = [...modifierKeys]; + return optionsContext; + } + + async _search(textSource, searchTerms, searchKanji, inputInfo) { + let definitions = null; + let sentence = null; + let type = null; + let error = null; + let searched = false; + let optionsContext = null; + let detail = null; + + try { + if (this._textSourceCurrent !== null && this._textSourceCurrent.hasSameStart(textSource)) { + return null; + } + + ({optionsContext, detail} = await this._getSearchContext()); + optionsContext = this._createOptionsContextForInput(optionsContext, inputInfo); + + searched = true; + + const result = await this._findDefinitions(textSource, searchTerms, searchKanji, optionsContext); + if (result !== null) { + ({definitions, sentence, type} = result); + this._inputInfoCurrent = inputInfo; + this.setCurrentTextSource(textSource); + } + } catch (e) { + error = e; + } + + if (!searched) { return null; } + + const results = { + textScanner: this, + type, + definitions, + sentence, + inputInfo, + textSource, + optionsContext, + detail, + error + }; + this.trigger('searched', results); + return results; + } + + _onMouseOver(e) { + if (this._ignoreElements !== null && this._ignoreElements().includes(e.target)) { + this._scanTimerClear(); + } + } + + _onMouseMove(e) { + this._scanTimerClear(); + + const inputInfo = this._getMatchingInputGroupFromEvent('mouse', 'mouseMove', e); + if (inputInfo === null) { return; } + + this._searchAtFromMouseMove(e.clientX, e.clientY, inputInfo); + } + + _onMouseDown(e) { + if (this._preventNextMouseDown) { + this._preventNextMouseDown = false; + this._preventNextClick = true; + e.preventDefault(); + e.stopPropagation(); + return false; + } + + switch (e.button) { + case 0: // Primary + this._scanTimerClear(); + this.clearSelection(false); + break; + case 1: // Middle + if (this._preventMiddleMouse) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + break; + } + } + + _onMouseOut() { + this._scanTimerClear(); + } + + _onClick(e) { + if (this._searchOnClick) { + const modifiers = DocumentUtil.getActiveModifiersAndButtons(e); + const modifierKeys = DocumentUtil.getActiveModifiers(e); + const inputInfo = this._createInputInfo(null, 'mouse', 'click', false, modifiers, modifierKeys); + this._searchAt(e.clientX, e.clientY, inputInfo); + } + + if (this._preventNextClick) { + this._preventNextClick = false; + e.preventDefault(); + e.stopPropagation(); + return false; + } + } + + _onAuxClick() { + this._preventNextContextMenu = false; + } + + _onContextMenu(e) { + if (this._preventNextContextMenu) { + this._preventNextContextMenu = false; + e.preventDefault(); + e.stopPropagation(); + return false; + } + } + + _onTouchStart(e) { + if (this._primaryTouchIdentifier !== null || e.changedTouches.length === 0) { + return; + } + + const {clientX, clientY, identifier} = e.changedTouches[0]; + this._onPrimaryTouchStart(e, clientX, clientY, identifier); + } + + _onPrimaryTouchStart(e, x, y, identifier) { + this._preventScroll = false; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + + if (DocumentUtil.isPointInSelection(x, y, window.getSelection())) { + return; + } + + this._primaryTouchIdentifier = identifier; + + this._searchAtFromTouchStart(e, x, y); + } + + _onTouchEnd(e) { + if ( + this._primaryTouchIdentifier === null || + this._getTouch(e.changedTouches, this._primaryTouchIdentifier) === null + ) { + return; + } + + this._onPrimaryTouchEnd(); + } + + _onPrimaryTouchEnd() { + this._primaryTouchIdentifier = null; + this._preventScroll = false; + this._preventNextClick = false; + // Don't revert context menu and mouse down prevention, since these events can occur after the touch has ended. + // I.e. this._preventNextContextMenu and this._preventNextMouseDown should not be assigned to false. + } + + _onTouchCancel(e) { + this._onTouchEnd(e); + } + + _onTouchMove(e) { + if (!this._preventScroll || !e.cancelable || this._primaryTouchIdentifier === null) { + return; + } + + const primaryTouch = this._getTouch(e.changedTouches, this._primaryTouchIdentifier); + if (primaryTouch === null) { + return; + } + + const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e); + if (inputInfo === null) { return; } + + if (inputInfo.input.options.scanOnTouchMove) { + this._searchAt(primaryTouch.clientX, primaryTouch.clientY, inputInfo); + } + + e.preventDefault(); // Disable scroll + } + + _onPointerOver(e) { + const {pointerType, pointerId, isPrimary} = e; + if (pointerType === 'pen') { + this._pointerIdTypeMap.set(pointerId, pointerType); + } + + if (!isPrimary) { return; } + switch (pointerType) { + case 'mouse': return this._onMousePointerOver(e); + case 'touch': return this._onTouchPointerOver(e); + case 'pen': return this._onPenPointerOver(e); + } + } + + _onPointerDown(e) { + if (!e.isPrimary) { return; } + switch (this._getPointerEventType(e)) { + case 'mouse': return this._onMousePointerDown(e); + case 'touch': return this._onTouchPointerDown(e); + case 'pen': return this._onPenPointerDown(e); + } + } + + _onPointerMove(e) { + if (!e.isPrimary) { return; } + switch (this._getPointerEventType(e)) { + case 'mouse': return this._onMousePointerMove(e); + case 'touch': return this._onTouchPointerMove(e); + case 'pen': return this._onPenPointerMove(e); + } + } + + _onPointerUp(e) { + if (!e.isPrimary) { return; } + switch (this._getPointerEventType(e)) { + case 'mouse': return this._onMousePointerUp(e); + case 'touch': return this._onTouchPointerUp(e); + case 'pen': return this._onPenPointerUp(e); + } + } + + _onPointerCancel(e) { + this._pointerIdTypeMap.delete(e.pointerId); + if (!e.isPrimary) { return; } + switch (e.pointerType) { + case 'mouse': return this._onMousePointerCancel(e); + case 'touch': return this._onTouchPointerCancel(e); + case 'pen': return this._onPenPointerCancel(e); + } + } + + _onPointerOut(e) { + this._pointerIdTypeMap.delete(e.pointerId); + if (!e.isPrimary) { return; } + switch (e.pointerType) { + case 'mouse': return this._onMousePointerOut(e); + case 'touch': return this._onTouchPointerOut(e); + case 'pen': return this._onPenPointerOut(e); + } + } + + _onMousePointerOver(e) { + return this._onMouseOver(e); + } + + _onMousePointerDown(e) { + return this._onMouseDown(e); + } + + _onMousePointerMove(e) { + return this._onMouseMove(e); + } + + _onMousePointerUp() { + // NOP + } + + _onMousePointerCancel(e) { + return this._onMouseOut(e); + } + + _onMousePointerOut(e) { + return this._onMouseOut(e); + } + + _onTouchPointerOver() { + // NOP + } + + _onTouchPointerDown(e) { + const {clientX, clientY, pointerId} = e; + return this._onPrimaryTouchStart(e, clientX, clientY, pointerId); + } + + _onTouchPointerMove(e) { + if (!this._preventScroll || !e.cancelable) { + return; + } + + const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e); + if (inputInfo === null || !inputInfo.input.options.scanOnTouchMove) { return; } + + this._searchAt(e.clientX, e.clientY, inputInfo); + } + + _onTouchPointerUp() { + return this._onPrimaryTouchEnd(); + } + + _onTouchPointerCancel() { + return this._onPrimaryTouchEnd(); + } + + _onTouchPointerOut() { + // NOP + } + + _onTouchMovePreventScroll(e) { + if (!this._preventScroll) { return; } + + if (e.cancelable) { + e.preventDefault(); + } else { + this._preventScroll = false; + } + } + + _onPenPointerOver(e) { + this._penPointerPressed = false; + this._penPointerReleased = false; + this._searchAtFromPen(e, e.clientX, e.clientY, 'pointerOver', false); + } + + _onPenPointerDown(e) { + this._penPointerPressed = true; + this._searchAtFromPen(e, e.clientX, e.clientY, 'pointerDown', true); + } + + _onPenPointerMove(e) { + if (this._penPointerPressed && (!this._preventScroll || !e.cancelable)) { return; } + this._searchAtFromPen(e, e.clientX, e.clientY, 'pointerMove', true); + } + + _onPenPointerUp() { + this._penPointerPressed = false; + this._penPointerReleased = true; + this._preventScroll = false; + } + + _onPenPointerCancel(e) { + this._onPenPointerOut(e); + } + + _onPenPointerOut() { + this._penPointerPressed = false; + this._penPointerReleased = false; + this._preventScroll = false; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + } + + async _scanTimerWait() { + const delay = this._delay; + const promise = promiseTimeout(delay, true); + this._scanTimerPromise = promise; + try { + return await promise; + } finally { + if (this._scanTimerPromise === promise) { + this._scanTimerPromise = null; + } + } + } + + _scanTimerClear() { + if (this._scanTimerPromise !== null) { + this._scanTimerPromise.resolve(false); + this._scanTimerPromise = null; + } + } + + _arePointerEventsSupported() { + return (this._pointerEventsEnabled && typeof PointerEvent !== 'undefined'); + } + + _hookEvents() { + let eventListenerInfos; + if (this._searchOnClickOnly) { + eventListenerInfos = this._getMouseClickOnlyEventListeners(); + } else if (this._arePointerEventsSupported()) { + eventListenerInfos = this._getPointerEventListeners(); + } else { + eventListenerInfos = this._getMouseEventListeners(); + if (this._touchInputEnabled) { + eventListenerInfos.push(...this._getTouchEventListeners()); + } + } + + for (const args of eventListenerInfos) { + this._eventListeners.addEventListener(...args); + } + } + + _getPointerEventListeners() { + return [ + [this._node, 'pointerover', this._onPointerOver.bind(this)], + [this._node, 'pointerdown', this._onPointerDown.bind(this)], + [this._node, 'pointermove', this._onPointerMove.bind(this)], + [this._node, 'pointerup', this._onPointerUp.bind(this)], + [this._node, 'pointercancel', this._onPointerCancel.bind(this)], + [this._node, 'pointerout', this._onPointerOut.bind(this)], + [this._node, 'touchmove', this._onTouchMovePreventScroll.bind(this), {passive: false}], + [this._node, 'mousedown', this._onMouseDown.bind(this)], + [this._node, 'click', this._onClick.bind(this)], + [this._node, 'auxclick', this._onAuxClick.bind(this)] + ]; + } + + _getMouseEventListeners() { + return [ + [this._node, 'mousedown', this._onMouseDown.bind(this)], + [this._node, 'mousemove', this._onMouseMove.bind(this)], + [this._node, 'mouseover', this._onMouseOver.bind(this)], + [this._node, 'mouseout', this._onMouseOut.bind(this)], + [this._node, 'click', this._onClick.bind(this)] + ]; + } + + _getMouseClickOnlyEventListeners() { + return [ + [this._node, 'click', this._onClick.bind(this)] + ]; + } + _getTouchEventListeners() { + return [ + [this._node, 'auxclick', this._onAuxClick.bind(this)], + [this._node, 'touchstart', this._onTouchStart.bind(this)], + [this._node, 'touchend', this._onTouchEnd.bind(this)], + [this._node, 'touchcancel', this._onTouchCancel.bind(this)], + [this._node, 'touchmove', this._onTouchMove.bind(this), {passive: false}], + [this._node, 'contextmenu', this._onContextMenu.bind(this)] + ]; + } + + _getTouch(touchList, identifier) { + for (const touch of touchList) { + if (touch.identifier === identifier) { + return touch; + } + } + return null; + } + + async _findDefinitions(textSource, searchTerms, searchKanji, optionsContext) { + if (textSource === null) { + return null; + } + if (searchTerms) { + const results = await this._findTerms(textSource, optionsContext); + if (results !== null) { return results; } + } + if (searchKanji) { + const results = await this._findKanji(textSource, optionsContext); + if (results !== null) { return results; } + } + return null; + } + + async _findTerms(textSource, optionsContext) { + const scanLength = this._scanLength; + const sentenceScanExtent = this._sentenceScanExtent; + const sentenceTerminatorMap = this._sentenceTerminatorMap; + const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; + const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; + const layoutAwareScan = this._layoutAwareScan; + const searchText = this.getTextSourceContent(textSource, scanLength, layoutAwareScan); + if (searchText.length === 0) { return null; } + + const {definitions, length} = await api.termsFind(searchText, {}, optionsContext); + if (definitions.length === 0) { return null; } + + textSource.setEndOffset(length, layoutAwareScan); + const sentence = this._documentUtil.extractSentence( + textSource, + layoutAwareScan, + sentenceScanExtent, + sentenceTerminatorMap, + sentenceForwardQuoteMap, + sentenceBackwardQuoteMap + ); + + return {definitions, sentence, type: 'terms'}; + } + + async _findKanji(textSource, optionsContext) { + const sentenceScanExtent = this._sentenceScanExtent; + const sentenceTerminatorMap = this._sentenceTerminatorMap; + const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; + const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; + const layoutAwareScan = this._layoutAwareScan; + const searchText = this.getTextSourceContent(textSource, 1, layoutAwareScan); + if (searchText.length === 0) { return null; } + + const definitions = await api.kanjiFind(searchText, optionsContext); + if (definitions.length === 0) { return null; } + + textSource.setEndOffset(1, layoutAwareScan); + const sentence = this._documentUtil.extractSentence( + textSource, + layoutAwareScan, + sentenceScanExtent, + sentenceTerminatorMap, + sentenceForwardQuoteMap, + sentenceBackwardQuoteMap + ); + + return {definitions, sentence, type: 'kanji'}; + } + + async _searchAt(x, y, inputInfo) { + if (this._pendingLookup) { return; } + + try { + const sourceInput = inputInfo.input; + let searchTerms = this._searchTerms; + let searchKanji = this._searchKanji; + if (sourceInput !== null) { + if (searchTerms && !sourceInput.options.searchTerms) { searchTerms = false; } + if (searchKanji && !sourceInput.options.searchKanji) { searchKanji = false; } + } + + this._pendingLookup = true; + this._scanTimerClear(); + + if (typeof this._ignorePoint === 'function' && await this._ignorePoint(x, y)) { + return; + } + + const textSource = this._documentUtil.getRangeFromPoint(x, y, this._deepContentScan); + try { + await this._search(textSource, searchTerms, searchKanji, inputInfo); + } finally { + if (textSource !== null) { + textSource.cleanup(); + } + } + } catch (e) { + yomichan.logError(e); + } finally { + this._pendingLookup = false; + } + } + + async _searchAtFromMouseMove(x, y, inputInfo) { + if (this._pendingLookup) { return; } + + if (inputInfo.passive) { + if (!await this._scanTimerWait()) { + // Aborted + return; + } + } + + await this._searchAt(x, y, inputInfo); + } + + async _searchAtFromTouchStart(e, x, y) { + if (this._pendingLookup) { return; } + + const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchStart', e); + if (inputInfo === null) { return; } + + const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null; + const preventScroll = inputInfo.input.options.preventTouchScrolling; + + await this._searchAt(x, y, inputInfo); + + if ( + this._textSourceCurrent !== null && + !this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious) + ) { + this._preventScroll = preventScroll; + this._preventNextContextMenu = true; + this._preventNextMouseDown = true; + } + } + + async _searchAtFromPen(e, x, y, eventType, prevent) { + if (this._pendingLookup) { return; } + + const inputInfo = this._getMatchingInputGroupFromEvent('pen', eventType, e); + if (inputInfo === null) { return; } + + const {input: {options}} = inputInfo; + if ( + (!options.scanOnPenRelease && this._penPointerReleased) || + !(this._penPointerPressed ? options.scanOnPenPress : options.scanOnPenHover) + ) { + return; + } + + const preventScroll = inputInfo.input.options.preventTouchScrolling; + + await this._searchAt(x, y, inputInfo); + + if ( + prevent && + this._textSourceCurrent !== null + ) { + this._preventScroll = preventScroll; + this._preventNextContextMenu = true; + this._preventNextMouseDown = true; + this._preventNextClick = true; + } + } + + _getMatchingInputGroupFromEvent(pointerType, eventType, event) { + const modifiers = DocumentUtil.getActiveModifiersAndButtons(event); + const modifierKeys = DocumentUtil.getActiveModifiers(event); + return this._getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys); + } + + _getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys) { + let fallbackIndex = -1; + const modifiersSet = new Set(modifiers); + for (let i = 0, ii = this._inputs.length; i < ii; ++i) { + const input = this._inputs[i]; + const {include, exclude, types} = input; + if (!types.has(pointerType)) { continue; } + if (this._setHasAll(modifiersSet, include) && (exclude.length === 0 || !this._setHasAll(modifiersSet, exclude))) { + if (include.length > 0) { + return this._createInputInfo(input, pointerType, eventType, false, modifiers, modifierKeys); + } else if (fallbackIndex < 0) { + fallbackIndex = i; + } + } + } + + return ( + fallbackIndex >= 0 ? + this._createInputInfo(this._inputs[fallbackIndex], pointerType, eventType, true, modifiers, modifierKeys) : + null + ); + } + + _createInputInfo(input, pointerType, eventType, passive, modifiers, modifierKeys, detail) { + return {input, pointerType, eventType, passive, modifiers, modifierKeys, detail}; + } + + _setHasAll(set, values) { + for (const value of values) { + if (!set.has(value)) { + return false; + } + } + return true; + } + + _getInputArray(value) { + return ( + typeof value === 'string' ? + value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0) : + [] + ); + } + + _getInputTypeSet({mouse, touch, pen}) { + const set = new Set(); + if (mouse) { set.add('mouse'); } + if (touch) { set.add('touch'); } + if (pen) { set.add('pen'); } + return set; + } + + _getPointerEventType(e) { + // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events. + const cachedPointerType = this._pointerIdTypeMap.get(e.pointerId); + return (typeof cachedPointerType !== 'undefined' ? cachedPointerType : e.pointerType); + } + + _constrainTextSource(textSource, includeSelector, excludeSelector, layoutAwareScan) { + let length = textSource.text().length; + while (length > 0) { + const nodes = textSource.getNodesInRange(); + if ( + (includeSelector !== null && !DocumentUtil.everyNodeMatchesSelector(nodes, includeSelector)) || + (excludeSelector !== null && DocumentUtil.anyNodeMatchesSelector(nodes, excludeSelector)) + ) { + --length; + textSource.setEndOffset(length, layoutAwareScan); + } else { + break; + } + } + } +} |