diff options
author | Alex Yatskov <alex@foosoft.net> | 2020-05-22 17:46:16 -0700 |
---|---|---|
committer | Alex Yatskov <alex@foosoft.net> | 2020-05-22 17:46:16 -0700 |
commit | 1480288561cb8b9fb87ad711d970c548329fea98 (patch) | |
tree | 87c2247f6d144407afcc6de316bbacc264582248 | |
parent | f2186c51e4ef219d158735d30a32bbf3e49c4e1a (diff) | |
parent | d0dcff765f740bf6f0f6523b09cb8b21eb85cd93 (diff) |
Merge branch 'master' into testing
97 files changed, 6412 insertions, 2570 deletions
diff --git a/.eslintrc.json b/.eslintrc.json index 8882cb42..3e384524 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,6 +23,7 @@ "eqeqeq": "error", "func-names": ["error", "always"], "guard-for-in": "error", + "new-parens": "error", "no-case-declarations": "error", "no-const-assign": "error", "no-constant-condition": "off", @@ -31,6 +32,7 @@ "no-prototype-builtins": "error", "no-shadow": ["error", {"builtinGlobals": false}], "no-undef": "error", + "no-unexpected-multiline": "error", "no-unneeded-ternary": "error", "no-unused-vars": ["error", {"vars": "local", "args": "after-used", "argsIgnorePattern": "^_", "caughtErrors": "none"}], "no-unused-expressions": "error", @@ -40,6 +42,7 @@ "quotes": ["error", "single", "avoid-escape"], "require-atomic-updates": "off", "semi": "error", + "wrap-iife": ["error", "inside"], // Whitespace rules "brace-style": ["error", "1tbs", {"allowSingleLine": true}], @@ -53,6 +56,7 @@ "comma-spacing": ["error", { "before": false, "after": true }], "computed-property-spacing": ["error", "never"], "func-call-spacing": ["error", "never"], + "function-paren-newline": ["error", "multiline-arguments"], "generator-star-spacing": ["error", "before"], "key-spacing": ["error", {"beforeColon": false, "afterColon": true, "mode": "strict"}], "keyword-spacing": ["error", {"before": true, "after": true}], @@ -61,6 +65,11 @@ "object-curly-spacing": ["error", "never"], "rest-spread-spacing": ["error", "never"], "semi-spacing": ["error", {"before": false, "after": true}], + "space-before-function-paren": ["error", { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + }], "space-in-parens": ["error", "never"], "space-unary-ops": "error", "spaced-comment": ["error", "always"], @@ -80,7 +89,6 @@ "yomichan": "readonly", "errorToJson": "readonly", "jsonToError": "readonly", - "logError": "readonly", "isObject": "readonly", "hasOwn": "readonly", "toIterable": "readonly", @@ -89,6 +97,8 @@ "parseUrl": "readonly", "areSetsEqual": "readonly", "getSetIntersection": "readonly", + "getSetDifference": "readonly", + "escapeRegExp": "readonly", "EventDispatcher": "readonly", "EventListenerCollection": "readonly", "EXTENSION_IS_BROWSER_EDGE": "readonly" @@ -2,9 +2,9 @@ Yomichan turns your web browser into a tool for building Japanese language literacy by helping you to decipher texts which would be otherwise too difficult tackle. This extension is similar to -[Rikaichan](https://addons.mozilla.org/en-US/firefox/addon/rikaichan/) for Firefox and +[Rikaichamp](https://addons.mozilla.org/en-US/firefox/addon/rikaichamp/) for Firefox and [Rikaikun](https://chrome.google.com/webstore/detail/rikaikun/jipdnfibhldikgcjhfnomkfpcebammhp?hl=en) for Chrome, but it -stands apart in its goal of being a all-encompassing learning tool as opposed to a mere browser-based dictionary. +stands apart in its goal of being an all-encompassing learning tool as opposed to a mere browser-based dictionary. Yomichan provides advanced features not available in other browser-based dictionaries: @@ -18,27 +18,27 @@ Yomichan provides advanced features not available in other browser-based diction ## Table of Contents ## -* [Installation](https://foosoft.net/projects/yomichan/#installation) -* [Dictionaries](https://foosoft.net/projects/yomichan/#dictionaries) -* [Basic Usage](https://foosoft.net/projects/yomichan/#basic-usage) -* [Custom Dictionaries](https://foosoft.net/projects/yomichan/#custom-dictionaries) -* [Anki Integration](https://foosoft.net/projects/yomichan/#anki-integration) - * [Flashcard Configuration](https://foosoft.net/projects/yomichan/#flashcard-configuration) - * [Flashcard Creation](https://foosoft.net/projects/yomichan/#flashcard-creation) -* [Keyboard Shortcuts](https://foosoft.net/projects/yomichan/#keyboard-shortcuts) -* [Development](https://foosoft.net/projects/yomichan/#development) - * [Dependencies](https://foosoft.net/projects/yomichan/#dependencies) -* [Frequently Asked Questions](https://foosoft.net/projects/yomichan/#frequently-asked-questions) -* [Screenshots](https://foosoft.net/projects/yomichan/#screenshots) -* [License](https://foosoft.net/projects/yomichan/#license) +* [Installation](#installation) +* [Dictionaries](#dictionaries) +* [Basic Usage](#basic-usage) +* [Custom Dictionaries](#custom-dictionaries) +* [Anki Integration](#anki-integration) + * [Flashcard Configuration](#flashcard-configuration) + * [Flashcard Creation](#flashcard-creation) +* [Keyboard Shortcuts](#keyboard-shortcuts) +* [Development](#development) + * [Dependencies](#dependencies) +* [Frequently Asked Questions](#frequently-asked-questions) +* [Screenshots](#screenshots) +* [License](#license) ## Installation ## Yomichan comes in two flavors: *stable* and *testing*. Over the years, this extension has evolved to contain many -complex features, which have become increasingly difficult for me to test across different browsers, versions, and -environments. All new changes are initially introduced into the *testing* version, and when I am reasonably confident -that they are bug free, they will get promoted to the *stable* version. If you are technically savvy and don't mind -submitting issues on GitHub, try the *testing* version, otherwise the *stable* version will be your best bet. +complex features which have become increasingly difficult to test across different browsers, versions, and +environments. New changes are initially introduced into the *testing* version, and after some time spent ensuring +that they are relatively bug free, they will be promoted to the *stable* version. If you are technically savvy and don't mind +submitting issues on GitHub, try the *testing* version; otherwise, the *stable* version will be your best bet. * **Google Chrome** ([stable](https://chrome.google.com/webstore/detail/yomichan/ogmnaimimemjmbakcfefmnahgdfhfami) or [testing](https://chrome.google.com/webstore/detail/yomichan-testing/bcknnfebhefllbjhagijobjklocakpdm)) @@ -54,10 +54,10 @@ submitting issues on GitHub, try the *testing* version, otherwise the *stable* v There are several free Japanese dictionaries available for Yomichan, with two of them having glossaries available in different languages. You must download and import the dictionaries you wish to use in order to enable Yomichan -definition lookups. If you have proprietary EPWING dictionaries that you would like to use, please see the [Yomichan +definition lookups. If you have proprietary EPWING dictionaries that you would like to use, check the [Yomichan Import](https://foosoft.net/projects/yomichan-import) page to learn how to convert and import them into Yomichan. -Please be aware that the non-English dictionaries contain fewer entries than their English counterparts. Even if your +Be aware that the non-English dictionaries contain fewer entries than their English counterparts. Even if your primary language is not English, you may consider also importing the English version for better coverage. * **[JMdict](https://www.edrdg.org/enamdict/enamdict_doc.html)** (Japanese vocabulary) @@ -74,19 +74,15 @@ primary language is not English, you may consider also importing the English ver * [jmnedict.zip](https://foosoft.net/projects/yomichan/dl/dict/jmnedict.zip) * **[KireiCake](https://kireicake.com/rikaicakes/)** (Japanese slang) * [kireicake.zip](https://foosoft.net/projects/yomichan/dl/dict/kireicake.zip) -* **[KANJIDIC](http://nihongo.monash.edu/kanjidic2/index.html)** (Japanese Kanji) +* **[KANJIDIC](http://nihongo.monash.edu/kanjidic2/index.html)** (Japanese kanji) * [kanjidic\_english.zip](https://foosoft.net/projects/yomichan/dl/dict/kanjidic_english.zip) * [kanjidic\_french.zip](https://foosoft.net/projects/yomichan/dl/dict/kanjidic_french.zip) * [kanjidic\_portuguese.zip](https://foosoft.net/projects/yomichan/dl/dict/kanjidic_portuguese.zip) * [kanjidic\_spanish.zip](https://foosoft.net/projects/yomichan/dl/dict/kanjidic_spanish.zip) -* **[Innocent Corpus](https://forum.koohii.com/post-168613.html#pid168613)** (Term and Kanji frequencies across 5000+ novels) +* **[Innocent Corpus](https://web.archive.org/web/20190309073023/https://forum.koohii.com/thread-9459.html#pid168613)** (Term and kanji frequencies across 5000+ novels) * [innocent\_corpus.zip](https://foosoft.net/projects/yomichan/dl/dict/innocent_corpus.zip) * **[Kanjium](https://github.com/mifunetoshiro/kanjium)** (Pitch dictionary, see [related project page](https://github.com/toasted-nutbread/yomichan-pitch-accent-dictionary) for details) -<<<<<<< HEAD * [kanjium_pitch_accents.zip](https://foosoft.net/projects/yomichan/dl/dict/kanjium_pitch_accents.zip) -======= - * [kanjium_pitch_accents.zip](https://foosoft.net/projects/yomichan/dl/dict/kanjium_pitch_accents.zip) (currently available for **testing version only**) ->>>>>>> master ## Basic Usage ## @@ -96,7 +92,7 @@ primary language is not English, you may consider also importing the English ver 2. Click on the *spanner/monkey wrench* icon in the middle to open the options page. -3. Import the dictionaries you wish to use for term and Kanji searches. If you do not have any dictionaries installed +3. Import the dictionaries you wish to use for term and kanji searches. If you do not have any dictionaries installed (or enabled), Yomichan will warn you that it is not ready for use by displaying an orange exclamation mark over its icon. This exclamation mark will disappear once you have installed and enabled at least one dictionary. @@ -112,21 +108,21 @@ primary language is not English, you may consider also importing the English ver not available, you will hear a short click instead. You can configure the sources used to retrieve audio samples in the options page. -6. Click on individual Kanji in the term definition results to view additional information about those characters +6. Click on individual kanji in the term definition results to view additional information about those characters, including stroke order diagrams, readings, meanings, as well as other useful data. [![](https://foosoft.net/projects/yomichan/img/ui-kanji-thumb.png)](https://foosoft.net/projects/yomichan/img/ui-kanji.png) ## Custom Dictionaries ## -Yomichan supports the use of custom dictionaries including the esoteric but popular +Yomichan supports the use of custom dictionaries, including the esoteric but popular [EPWING](https://ja.wikipedia.org/wiki/EPWING) format. They were often utilized in portable electronic dictionaries similar to the ones pictured below. These dictionaries are often sought after by language learners for their correctness and excellent coverage of the Japanese language. -Unfortunately, as most of the dictionaries released in this format are proprietary I am unable to bundle them with -Yomichan. You will need to procure these dictionaries yourself and import them with [Yomichan -Import](https://foosoft.net/projects/yomichan-import). Please see the project page for additional details. +Unfortunately, as most of the dictionaries released in this format are proprietary, they are unable to be bundled with +Yomichan. Instead, you will need to procure these dictionaries yourself and import them using [Yomichan +Import](https://foosoft.net/projects/yomichan-import). Check the project page for additional details. [![Pocket EPWING dictionaries](https://foosoft.net/projects/yomichan/img/epwing-devices-thumb.png)](https://foosoft.net/projects/yomichan/img/epwing-devices.jpg) @@ -134,13 +130,13 @@ Import](https://foosoft.net/projects/yomichan-import). Please see the project pa Yomichan features automatic flashcard creation for [Anki](https://apps.ankiweb.net/), a free application designed to help you retain knowledge. This feature requires the prior installation of an Anki plugin called [AnkiConnect](https://foosoft.net/projects/anki-connect). -Please see the respective project page for more information about how to set up this software. +Check the respective project page for more information about how to set up this software. ### Flashcard Configuration ### -Before flashcards can be automatically created, you must configure the templates used to create term and/or Kanji notes. +Before flashcards can be automatically created, you must configure the templates used to create term and/or kanji notes. If you are unfamiliar with Anki deck and model management, this would be a good time to reference the [Anki -Manual](https://apps.ankiweb.net/docs/manual.html). In short, you must specify what information should be included in the +Manual](https://docs.ankiweb.net/#/). In short, you must specify what information should be included in the flashcards that Yomichan creates through AnkiConnect. Flashcard fields can be configured with the following steps: @@ -159,17 +155,17 @@ Flashcard fields can be configured with the following steps: -------|------------ `{audio}` | Audio sample of a native speaker's pronunciation in MP3 format (if available). `{cloze-body}` | Raw, inflected term as it appeared before being reduced to dictionary form by Yomichan. - `{cloze-prefix}` | Text for the containing `{sentence}` from the start up to the value of `{cloze-body}`. - `{cloze-suffix}` | Text for the containing `{sentence}` from the value of `{cloze-body}` to the end. + `{cloze-prefix}` | Fragment of the containing `{sentence}` starting at the beginning of `{sentence}` until the beginning of `{cloze-body}`. + `{cloze-suffix}` | Fragment of the containing `{sentence}` starting at the end of `{cloze-body}` until the end of `{sentence}`. `{dictionary}` | Name of the dictionary from which the card is being created (unavailable in *grouped* mode). `{document-title}` | Title of the web page that the term appeared in. - `{expression}` | Term expressed as Kanji (will be displayed in Kana if Kanji is not available). - `{furigana}` | Term expressed as Kanji with Furigana displayed above it (e.g. <ruby>日本語<rt>にほんご</rt></ruby>). - `{furigana-plain}` | Term expressed as Kanji with Furigana displayed next to it in brackets (e.g. 日本語[にほんご]). + `{expression}` | Term expressed as kanji (will be displayed in kana if kanji is not available). + `{furigana}` | Term expressed as kanji with furigana displayed above it (e.g. <ruby>日本語<rt>にほんご</rt></ruby>). + `{furigana-plain}` | Term expressed as kanji with furigana displayed next to it in brackets (e.g. 日本語[にほんご]). `{glossary}` | List of definitions for the term (output format depends on whether running in *grouped* mode). `{reading}` | Kana reading for the term (empty for terms where the expression is the reading). `{screenshot}` | Screenshot of the web page taken at the time the term was added. - `{sentence}` | Sentence, quote, or phrase in which the term appears in the source content. + `{sentence}` | Sentence, quote, or phrase that the term appears in from the source content. `{tags}` | Grammar and usage tags providing information about the term (unavailable in *grouped* mode). `{url}` | Address of the web page in which the term appeared in. @@ -177,38 +173,38 @@ Flashcard fields can be configured with the following steps: Marker | Description -------|------------ - `{character}` | Unicode glyph representing the current Kanji. + `{character}` | Unicode glyph representing the current kanji. `{cloze-body}` | Raw, inflected parent term as it appeared before being reduced to dictionary form by Yomichan. - `{cloze-prefix}` | Text for the containing `{sentence}` from the start up to the value of `{cloze-body}`. - `{cloze-suffix}` | Text for the containing `{sentence}` from the value of `{cloze-body}` to the end. + `{cloze-prefix}` | Fragment of the containing `{sentence}` starting at the beginning of `{sentence}` until the beginning of `{cloze-body}`. + `{cloze-suffix}` | Fragment of the containing `{sentence}` starting at the end of `{cloze-body}` until the end of `{sentence}`. `{dictionary}` | Name of the dictionary from which the card is being created. - `{document-title}` | Title of the web page that the Kanji appeared in. - `{glossary}` | List of definitions for the Kanji. - `{kunyomi}` | Kunyomi (Japanese reading) for the Kanji expressed as Katakana. - `{onyomi}` | Onyomi (Chinese reading) for the Kanji expressed as Hiragana. - `{screenshot}` | Screenshot of the web page taken at the time the Kanji was added. - `{sentence}` | Sentence, quote, or phrase in which the character appears in the source content. - `{url}` | Address of the web page in which the Kanji appeared in. - -When creating your model for Yomichan, *please make sure that you pick a unique field to be first*; fields that will -contain `{expression}` or `{character}` are ideal candidates for this. Anki does not require duplicate flashcards to be -added to a deck and uses the first field in the model to check for duplicates. If, for example, you have `{reading}` -configured to be the first field in your model and already have <ruby>橋<rt>はし</rt></ruby> in your deck, you will not -be able to create a flashcard for <ruby>箸<rt>はし</rt></ruby>, because they share the same reading. + `{document-title}` | Title of the web page that the kanji appeared in. + `{glossary}` | List of definitions for the kanji. + `{kunyomi}` | Kunyomi (Japanese reading) for the kanji expressed as katakana. + `{onyomi}` | Onyomi (Chinese reading) for the kanji expressed as hiragana. + `{screenshot}` | Screenshot of the web page taken at the time the kanji was added. + `{sentence}` | Sentence, quote, or phrase that the character appears in from the source content. + `{url}` | Address of the web page in which the kanji appeared in. + +When creating your model for Yomichan, *make sure that you pick a unique field to be first*; fields that will +contain `{expression}` or `{character}` are ideal candidates for this. Anki does not allow duplicate flashcards to be +added to a deck by default; it uses the first field in the model to check for duplicates. For example, if you have `{reading}` +configured to be the first field in your model and <ruby>橋<rt>はし</rt></ruby> is already in your deck, you will not +be able to create a flashcard for <ruby>箸<rt>はし</rt></ruby> because they share the same reading. ### Flashcard Creation ### Once Yomichan is configured, it becomes trivial to create new flashcards with a single click. You will see the following -icons next to term definitions. +icons next to term definitions: -* Clicking ![](https://foosoft.net/projects/yomichan/img/btn-add-expression.png) adds the current expression as Kanji (e.g. 食べる). -* Clicking ![](https://foosoft.net/projects/yomichan/img/btn-add-reading.png) adds the current expression as Hiragana or Katakana (e.g. たべる). +* Clicking ![](https://foosoft.net/projects/yomichan/img/btn-add-expression.png) adds the current expression as kanji (e.g. 食べる). +* Clicking ![](https://foosoft.net/projects/yomichan/img/btn-add-reading.png) adds the current expression as hiragana or katakana (e.g. たべる). Below are some troubleshooting tips you can try if you are unable to create new flashcards: * Individual icons will appear grayed out if a flashcard cannot be created for the current definition (e.g. it already exists in the deck). -* If all of the buttons appear grayed out then you should double-check your deck and model configuration settings. -* If no icons appear at all, please make sure that Anki is running in the background and that [AnkiConnect](https://foosoft.net/projects/anki-connect) has been installed. +* If all of the buttons appear grayed out, then you should double-check your deck and model configuration settings. +* If no icons appear at all, make sure that Anki is running in the background and that [AnkiConnect](https://foosoft.net/projects/anki-connect) has been installed. ## Keyboard Shortcuts ## @@ -223,7 +219,7 @@ The following shortcuts are available on search results: Shortcut | Action ---------|------- -<kbd>Esc</kbd> | Cancel current search +<kbd>Esc</kbd> | Cancel current search. <kbd>Alt</kbd> + <kbd>PgUp</kbd> | Page up through results. <kbd>Alt</kbd> + <kbd>PgDn</kbd> | Page down through results. <kbd>Alt</kbd> + <kbd>End</kbd> | Go to last result. @@ -234,17 +230,18 @@ Shortcut | Action <kbd>Alt</kbd> + <kbd>e</kbd> | Add current term as expression to Anki. <kbd>Alt</kbd> + <kbd>r</kbd> | Add current term as reading to Anki. <kbd>Alt</kbd> + <kbd>p</kbd> | Play audio for current term. -<kbd>Alt</kbd> + <kbd>k</kbd> | Add current Kanji to Anki. +<kbd>Alt</kbd> + <kbd>k</kbd> | Add current kanji to Anki. ## Development ## -Working on Yomichan and related tools is very time consuming and I am always on the lookout for code contributions from -other developers who want to help out. I take pride in the high quality of the codebase and ask you to follow the -following basic guidelines when creating pull requests: +As feature development and improvements to Yomichan can be time consuming, contributions are welcome from +any developers who want to help out. Below are a few guidelines to ensure contributions have a good level +of quality and consistency: -* Please discuss large features before writing code. +* Open GitHub issues to discuss large features before writing code. * Follow the [conventions and style](.eslintrc.json) of the existing code. -* Write clean, modern ES6 code (`const`/`let`, `await`, arrow functions, etc.) +* Test changes using the continuous integration tests included in the repository (`npm ci; npm test`). +* Write clean, modern ES6 code (`const`/`let`, `async`/`await`, arrow functions, etc.) * Large pull requests without a clear scope will not be merged. * Incomplete or non-standalone features will not be merged. @@ -264,7 +261,7 @@ versions packaged. **I'm having problems importing dictionaries in Firefox, what do I do?** Yomichan uses the cross-browser IndexedDB system for storing imported dictionary data into your user profile. Although -everything "just works" in Chrome, depending on settings, Firefox users can run into problems due browser bugs. +everything "just works" in Chrome, depending on settings, Firefox users can run into problems due to browser bugs. Yomichan catches errors and tries to offer suggestions about how to work around Firefox issues, but in general at least one of the following solutions should work for you: @@ -281,27 +278,27 @@ one of the following solutions should work for you: feature to reset your user profile. It appears that the Firefox profile system can corrupt itself preventing IndexedDB from being accessible to Yomichan. -**Why does the Kanji results page display "No data found" for several fields?** +**Why does the kanji results page display "No data found" for several fields?** You are using data from the KANJIDIC dictionary that was exported for an earlier version of Yomichan. It does not -contain the additional information which newer versions of Yofomichan expect. Unfortunately, since major browser -implementations of IndexedDB do not provide reliable means for selective bulk data deletion, you will need purge +contain the additional information which newer versions of Yomichan expect. Unfortunately, since major browser +implementations of IndexedDB do not provide reliable means for selective bulk data deletion, you will need to purge your database and install the latest version of the KANJIDIC to see additional information about characters. **Can I still create cards without HTML formatting? The option for it is gone!** Developing Yomichan is a constant balance between including useful features and keeping complexity at a minimum. With the new user-editable card template system, it is possible to create text-only cards without having to double -the number field of templates in the extension itself. If you would like to stop HTML tags from being added to your -cards, simply copy the contents of the <a href="dl/fields.txt">text-only field template</a> into the template box on -the Anki settings page (make sure you have the *Show advanced options* checkbox ticked), making sure to replace the -existing values. +the number of field templates in the extension itself. If you would like to stop HTML tags from being added to your +cards, simply copy the contents of the [text-only field template](https://foosoft.net/projects/yomichan/dl/fields.txt) +into the template box on the Anki settings page (make sure you have the *Show advanced options* checkbox ticked), +making sure to replace the existing values. **Will you add support for online dictionaries?** -Online dictionaries will never be implemented because it is impossible to support them in a robust way. In order to -perform Japanese deinflection, Yomichan must execute dozens of database queries per every single word. Factoring in -network latency and the fragility of web scraping, I do not believe that it is possible to realize a good user +Online dictionaries will not be implemented because it is not possible to support them in a robust way. In order to +perform Japanese deinflection, Yomichan must execute dozens of database queries for every single word. Factoring in +network latency and the fragility of web scraping, it would not be possible to maintain a good and consistent user experience. **Is it possible to use Yomichan with files saved locally on my computer with Chrome?** @@ -312,21 +309,22 @@ will likely never be possible to use Yomichan with PDF files. **Is it possible to delete individual dictionaries without purging the database?** -Although it is technically possible to purge specific dictionaries, due to the limitations of the underlying browser -IndexedDB system, this process is *extremely* slow. For example, it can take up to ten minutes to delete a single -moderately-sized term dictionary! Instead of including a borderline unusable feature in Yomichan, I have chosen to -disable dictionary deletion entirely. +Yomichan is able to delete individual dictionaries, but keep in mind that this process can be *very* slow and can +cause the browser to become unresponsive. The time it takes to delete a single dictionary can sometimes be roughly +the same as the time it originally took to import, which can be significant for certain large dictionaries. **Why aren't EPWING dictionaries bundled with Yomichan?** -The vast majority of EPWING dictionaries are proprietary, so unfortunately I am unable to legally include them in -this extension for copyright reasons. +The vast majority of EPWING dictionaries are proprietary, so they are unfortunately not able to be included in +this extension due to copyright reasons. **When are you going to add support for $MYLANGUAGE?** -Developing Yomichan requires a significant understanding of Japanese sentence structure and grammar. I have no time -to invest in learning yet another language; therefore other languages will not be supported. I will also not accept -pull request containing this functionality, as I will ultimately be the one maintaining your code. +Developing Yomichan requires a decent understanding of Japanese sentence structure and grammar, and other languages +are likely to have their own unique set of rules for syntax, grammar, inflection, and so on. Supporting additional +languages would not only require many additional changes to the codebase, it would also incur significant maintenance +overhead and knowledge demands for the developers. Therefore, suggestions and contributions for supporting +new languages will be declined, allowing Yomichan's focus to remain Japanese-centric. ## Legal ## diff --git a/ext/bg/background.html b/ext/bg/background.html index afe9c5d1..ca35a3c6 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -6,6 +6,7 @@ <title>Background</title> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> @@ -20,10 +21,12 @@ <script src="/mixed/js/core.js"></script> <script src="/mixed/js/dom.js"></script> + <script src="/mixed/js/environment.js"></script> <script src="/mixed/js/japanese.js"></script> <script src="/bg/js/anki.js"></script> <script src="/bg/js/anki-note-builder.js"></script> + <script src="/bg/js/backend.js"></script> <script src="/bg/js/mecab.js"></script> <script src="/bg/js/audio-uri-builder.js"></script> <script src="/bg/js/backend-api-forwarder.js"></script> @@ -34,8 +37,8 @@ <script src="/bg/js/deinflector.js"></script> <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> - <script src="/bg/js/japanese.js"></script> <script src="/bg/js/json-schema.js"></script> + <script src="/bg/js/media-utility.js"></script> <script src="/bg/js/options.js"></script> <script src="/bg/js/profile-conditions.js"></script> <script src="/bg/js/request.js"></script> @@ -43,7 +46,8 @@ <script src="/bg/js/translator.js"></script> <script src="/bg/js/util.js"></script> <script src="/mixed/js/audio-system.js"></script> + <script src="/mixed/js/object-property-accessor.js"></script> - <script src="/bg/js/backend.js"></script> + <script src="/bg/js/background-main.js"></script> </body> </html> diff --git a/ext/bg/context.html b/ext/bg/context.html index 0e50ed7c..93012d70 100644 --- a/ext/bg/context.html +++ b/ext/bg/context.html @@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width,initial-scale=1" /> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> @@ -185,6 +186,6 @@ <script src="/bg/js/options.js"></script> <script src="/bg/js/util.js"></script> - <script src="/bg/js/context.js"></script> + <script src="/bg/js/context-main.js"></script> </body> </html> diff --git a/ext/bg/data/default-anki-field-templates.handlebars b/ext/bg/data/default-anki-field-templates.handlebars index 6061851f..42deae23 100644 --- a/ext/bg/data/default-anki-field-templates.handlebars +++ b/ext/bg/data/default-anki-field-templates.handlebars @@ -14,7 +14,11 @@ {{~/if~}} {{/inline}} -{{#*inline "audio"}}{{/inline}} +{{#*inline "audio"}} + {{~#if definition.audioFileName~}} + [sound:{{definition.audioFileName}}] + {{~/if~}} +{{/inline}} {{#*inline "character"}} {{~definition.character~}} @@ -147,7 +151,7 @@ {{/inline}} {{#*inline "tags"}} - {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}} + {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}} {{/inline}} {{#*inline "url"}} @@ -162,4 +166,4 @@ {{~context.document.title~}} {{/inline}} -{{~> (lookup . "marker") ~}}
\ No newline at end of file +{{~> (lookup . "marker") ~}} diff --git a/ext/bg/data/dictionary-term-bank-v3-schema.json b/ext/bg/data/dictionary-term-bank-v3-schema.json index bb982e36..4790e561 100644 --- a/ext/bg/data/dictionary-term-bank-v3-schema.json +++ b/ext/bg/data/dictionary-term-bank-v3-schema.json @@ -31,8 +31,85 @@ "type": "array", "description": "Array of definitions for the term/expression.", "items": { - "type": "string", - "description": "Single definition for the term/expression." + "oneOf": [ + { + "type": "string", + "description": "Single definition for the term/expression." + }, + { + "type": "object", + "description": "Single detailed definition for the term/expression.", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the data for this definition.", + "enum": ["text", "image"] + } + }, + "oneOf": [ + { + "required": [ + "type", + "text" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string", + "description": "Single definition for the term/expression." + } + } + }, + { + "required": [ + "type", + "path" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["image"] + }, + "path": { + "type": "string", + "description": "Path to the image file in the archive." + }, + "width": { + "type": "integer", + "description": "Preferred width of the image.", + "minimum": 1 + }, + "height": { + "type": "integer", + "description": "Preferred width of the image.", + "minimum": 1 + }, + "title": { + "type": "string", + "description": "Hover text for the image." + }, + "description": { + "type": "string", + "description": "Description of the image." + }, + "pixelated": { + "type": "boolean", + "description": "Whether or not the image should appear pixelated at sizes larger than the image's native resolution.", + "default": false + } + } + } + ] + } + ] } }, { diff --git a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json index 8475db81..ffffb546 100644 --- a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json +++ b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json @@ -26,8 +26,30 @@ {}, {"enum": ["freq"]}, { - "type": ["string", "number"], - "description": "Frequency information for the term or expression." + "oneOf": [ + { + "type": ["string", "number"], + "description": "Frequency information for the term or expression." + }, + { + "type": ["object"], + "required": [ + "reading", + "frequency" + ], + "additionalProperties": false, + "properties": { + "reading": { + "type": "string", + "description": "Reading for the term or expression." + }, + "frequency": { + "type": ["string", "number"], + "description": "Frequency information for the term or expression." + } + } + } + ] } ] }, diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index 4f9e694d..656da989 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -351,7 +351,7 @@ }, "modifier": { "type": "string", - "enum": ["none", "alt", "ctrl", "shift"], + "enum": ["none", "alt", "ctrl", "shift", "meta"], "default": "shift" }, "deepDomScan": { @@ -492,6 +492,7 @@ "screenshot", "terms", "kanji", + "duplicateScope", "fieldTemplates" ], "properties": { @@ -587,6 +588,11 @@ } } }, + "duplicateScope": { + "type": "string", + "default": "collection", + "enum": ["collection", "deck"] + }, "fieldTemplates": { "type": ["string", "null"], "default": null diff --git a/ext/bg/guide.html b/ext/bg/guide.html index ff9c71ee..cde520d1 100644 --- a/ext/bg/guide.html +++ b/ext/bg/guide.html @@ -6,6 +6,7 @@ <title>Welcome to Yomichan!</title> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> @@ -25,7 +26,7 @@ </p> <ol> - <li>Click on the <img src="/mixed/img/icon16.png" alt> icon in the browser toolbar to open the Yomichan actions dialog.</li> + <li>Click on the <img src="/mixed/img/yomichan-icon.svg" alt> icon in the browser toolbar to open the Yomichan actions dialog.</li> <li>Click on the <em>monkey wrench</em> icon in the middle to open the options page.</li> <li>Import the dictionaries you wish to use for term and Kanji searches.</li> <li>Hold down <kbd>Shift</kbd> key or the middle mouse button as you move your mouse over text to display definitions.</li> diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js index 8a707006..76199db7 100644 --- a/ext/bg/js/anki-note-builder.js +++ b/ext/bg/js/anki-note-builder.js @@ -16,7 +16,8 @@ */ class AnkiNoteBuilder { - constructor({audioSystem, renderTemplate}) { + constructor({anki, audioSystem, renderTemplate}) { + this._anki = anki; this._audioSystem = audioSystem; this._renderTemplate = renderTemplate; } @@ -31,32 +32,16 @@ class AnkiNoteBuilder { fields: {}, tags, deckName: modeOptions.deck, - modelName: modeOptions.model + modelName: modeOptions.model, + options: { + duplicateScope: options.anki.duplicateScope + } }; for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null); } - if (!isKanji && definition.audio) { - const audioFields = []; - - for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { - if (fieldValue.includes('{audio}')) { - audioFields.push(fieldName); - } - } - - if (audioFields.length > 0) { - note.audio = { - url: definition.audio.url, - filename: definition.audio.filename, - skipHash: '7e2c2f954ef6051373ba916f000168dc', // hash of audio data that should be skipped - fields: audioFields - }; - } - } - return note; } @@ -84,48 +69,64 @@ class AnkiNoteBuilder { }); } - async injectAudio(definition, fields, sources, optionsContext) { + async injectAudio(definition, fields, sources, customSourceUrl) { if (!this._containsMarker(fields, 'audio')) { return; } try { const expressions = definition.expressions; const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition; - const {uri} = await this._audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext}); - const filename = this._createInjectedAudioFileName(audioSourceDefinition); - if (filename !== null) { - definition.audio = {url: uri, filename}; - } + let fileName = this._createInjectedAudioFileName(audioSourceDefinition); + if (fileName === null) { return; } + fileName = AnkiNoteBuilder.replaceInvalidFileNameCharacters(fileName); + + const {audio} = await this._audioSystem.getDefinitionAudio( + audioSourceDefinition, + sources, + { + textToSpeechVoice: null, + customSourceUrl, + binary: true, + disableCache: true + } + ); + + const data = AnkiNoteBuilder.arrayBufferToBase64(audio); + await this._anki.storeMediaFile(fileName, data); + + definition.audioFileName = fileName; } catch (e) { // NOP } } - async injectScreenshot(definition, fields, screenshot, anki) { + async injectScreenshot(definition, fields, screenshot) { if (!this._containsMarker(fields, 'screenshot')) { return; } const now = new Date(Date.now()); - const filename = `yomichan_browser_screenshot_${definition.reading}_${this._dateToString(now)}.${screenshot.format}`; + let fileName = `yomichan_browser_screenshot_${definition.reading}_${this._dateToString(now)}.${screenshot.format}`; + fileName = AnkiNoteBuilder.replaceInvalidFileNameCharacters(fileName); const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, ''); try { - await anki.storeMediaFile(filename, data); + await this._anki.storeMediaFile(fileName, data); } catch (e) { return; } - definition.screenshotFileName = filename; + definition.screenshotFileName = fileName; } _createInjectedAudioFileName(definition) { const {reading, expression} = definition; if (!reading && !expression) { return null; } - let filename = 'yomichan'; - if (reading) { filename += `_${reading}`; } - if (expression) { filename += `_${expression}`; } - filename += '.mp3'; - return filename; + let fileName = 'yomichan'; + if (reading) { fileName += `_${reading}`; } + if (expression) { fileName += `_${expression}`; } + fileName += '.mp3'; + fileName = fileName.replace(/\]/g, ''); + return fileName; } _dateToString(date) { @@ -148,6 +149,15 @@ class AnkiNoteBuilder { return false; } + static replaceInvalidFileNameCharacters(fileName) { + // eslint-disable-next-line no-control-regex + return fileName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-'); + } + + static arrayBufferToBase64(arrayBuffer) { + return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + } + static stringReplaceAsync(str, regex, replacer) { let match; let index = 0; diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js index c7f7c0cc..55953007 100644 --- a/ext/bg/js/anki.js +++ b/ext/bg/js/anki.js @@ -19,122 +19,118 @@ * requestJson */ -/* - * AnkiConnect - */ - class AnkiConnect { constructor(server) { - this.server = server; - this.localVersion = 2; - this.remoteVersion = 0; + this._enabled = false; + this._server = server; + this._localVersion = 2; + this._remoteVersion = 0; + } + + setServer(server) { + this._server = server; + } + + getServer() { + return this._server; + } + + setEnabled(enabled) { + this._enabled = enabled; + } + + isEnabled() { + return this._enabled; } async addNote(note) { - await this.checkVersion(); - return await this.ankiInvoke('addNote', {note}); + if (!this._enabled) { return null; } + await this._checkVersion(); + return await this._invoke('addNote', {note}); } async canAddNotes(notes) { - await this.checkVersion(); - return await this.ankiInvoke('canAddNotes', {notes}); + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('canAddNotes', {notes}); } async getDeckNames() { - await this.checkVersion(); - return await this.ankiInvoke('deckNames'); + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('deckNames'); } async getModelNames() { - await this.checkVersion(); - return await this.ankiInvoke('modelNames'); + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('modelNames'); } async getModelFieldNames(modelName) { - await this.checkVersion(); - return await this.ankiInvoke('modelFieldNames', {modelName}); + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('modelFieldNames', {modelName}); } async guiBrowse(query) { - await this.checkVersion(); - return await this.ankiInvoke('guiBrowse', {query}); + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('guiBrowse', {query}); } - async storeMediaFile(filename, dataBase64) { - await this.checkVersion(); - return await this.ankiInvoke('storeMediaFile', {filename, data: dataBase64}); + async storeMediaFile(fileName, dataBase64) { + if (!this._enabled) { + throw new Error('AnkiConnect not enabled'); + } + await this._checkVersion(); + return await this._invoke('storeMediaFile', {filename: fileName, data: dataBase64}); } - async checkVersion() { - if (this.remoteVersion < this.localVersion) { - this.remoteVersion = await this.ankiInvoke('version'); - if (this.remoteVersion < this.localVersion) { + async findNoteIds(notes, duplicateScope) { + if (!this._enabled) { return []; } + await this._checkVersion(); + const actions = notes.map((note) => { + let query = (duplicateScope === 'deck' ? `"deck:${this._escapeQuery(note.deckName)}" ` : ''); + query += this._fieldsToQuery(note.fields); + return {action: 'findNotes', params: {query}}; + }); + return await this._invoke('multi', {actions}); + } + + // Private + + async _checkVersion() { + if (this._remoteVersion < this._localVersion) { + this._remoteVersion = await this._invoke('version'); + if (this._remoteVersion < this._localVersion) { throw new Error('Extension and plugin versions incompatible'); } } } - async findNoteIds(notes) { - await this.checkVersion(); - const actions = notes.map((note) => ({ - action: 'findNotes', - params: { - query: `deck:"${AnkiConnect.escapeQuery(note.deckName)}" ${AnkiConnect.fieldsToQuery(note.fields)}` + async _invoke(action, params) { + const result = await requestJson(this._server, 'POST', {action, params, version: this._localVersion}); + if (isObject(result)) { + const error = result.error; + if (typeof error !== 'undefined') { + throw new Error(`AnkiConnect error: ${error}`); } - })); - return await this.ankiInvoke('multi', {actions}); - } - - ankiInvoke(action, params) { - return requestJson(this.server, 'POST', {action, params, version: this.localVersion}); + } + return result; } - static escapeQuery(text) { + _escapeQuery(text) { return text.replace(/"/g, ''); } - static fieldsToQuery(fields) { + _fieldsToQuery(fields) { const fieldNames = Object.keys(fields); if (fieldNames.length === 0) { return ''; } const key = fieldNames[0]; - return `${key.toLowerCase()}:"${AnkiConnect.escapeQuery(fields[key])}"`; - } -} - - -/* - * AnkiNull - */ - -class AnkiNull { - async addNote() { - return null; - } - - async canAddNotes() { - return []; - } - - async getDeckNames() { - return []; - } - - async getModelNames() { - return []; - } - - async getModelFieldNames() { - return []; - } - - async guiBrowse() { - return []; - } - - async findNoteIds() { - return []; + return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`; } } diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js index dfd195d8..27e97680 100644 --- a/ext/bg/js/audio-uri-builder.js +++ b/ext/bg/js/audio-uri-builder.js @@ -49,11 +49,11 @@ class AudioUriBuilder { return url; } - async getUri(definition, source, options) { + async getUri(definition, source, details) { const handler = this._getUrlHandlers.get(source); if (typeof handler === 'function') { try { - return await handler(definition, options); + return await handler(definition, details); } catch (e) { // NOP } @@ -132,26 +132,24 @@ class AudioUriBuilder { throw new Error('Failed to find audio URL'); } - async _getUriTextToSpeech(definition, options) { - const voiceURI = options.audio.textToSpeechVoice; - if (!voiceURI) { + async _getUriTextToSpeech(definition, {textToSpeechVoice}) { + if (!textToSpeechVoice) { throw new Error('No voice'); } - - return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; + return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`; } - async _getUriTextToSpeechReading(definition, options) { - const voiceURI = options.audio.textToSpeechVoice; - if (!voiceURI) { + async _getUriTextToSpeechReading(definition, {textToSpeechVoice}) { + if (!textToSpeechVoice) { throw new Error('No voice'); } - - return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; + return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`; } - async _getUriCustom(definition, options) { - const customSourceUrl = options.audio.customSourceUrl; + async _getUriCustom(definition, {customSourceUrl}) { + if (typeof customSourceUrl !== 'string') { + throw new Error('No custom URL defined'); + } return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0)); } } diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js index 93db77d7..4ac12730 100644 --- a/ext/bg/js/backend-api-forwarder.js +++ b/ext/bg/js/backend-api-forwarder.js @@ -17,11 +17,11 @@ class BackendApiForwarder { - constructor() { - chrome.runtime.onConnect.addListener(this.onConnect.bind(this)); + prepare() { + chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); } - onConnect(port) { + _onConnect(port) { if (port.name !== 'backend-api-forwarder') { return; } let tabId; diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 2265c1a9..20d31efc 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -18,24 +18,25 @@ /* global * AnkiConnect * AnkiNoteBuilder - * AnkiNull * AudioSystem * AudioUriBuilder * BackendApiForwarder * ClipboardMonitor * Database * DictionaryImporter + * Environment * JsonSchema * Mecab + * ObjectPropertyAccessor * Translator * conditionsTestValue - * dictConfigured * dictTermsSort * handlebarsRenderDynamic * jp * optionsLoad * optionsSave * profileConditionsDescriptor + * profileConditionsDescriptorPromise * requestJson * requestText * utilIsolate @@ -43,18 +44,23 @@ class Backend { constructor() { + this.environment = new Environment(); this.database = new Database(); this.dictionaryImporter = new DictionaryImporter(); this.translator = new Translator(this.database); - this.anki = new AnkiNull(); + this.anki = new AnkiConnect(); this.mecab = new Mecab(); this.clipboardMonitor = new ClipboardMonitor({getClipboard: this._onApiClipboardGet.bind(this)}); this.options = null; this.optionsSchema = null; this.defaultAnkiFieldTemplates = null; - this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)}); this.audioUriBuilder = new AudioUriBuilder(); + this.audioSystem = new AudioSystem({ + audioUriBuilder: this.audioUriBuilder, + useCache: false + }); this.ankiNoteBuilder = new AnkiNoteBuilder({ + anki: this.anki, audioSystem: this.audioSystem, renderTemplate: this._renderTemplate.bind(this) }); @@ -64,89 +70,128 @@ class Backend { url: window.location.href }; - this.isPrepared = false; - this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); this.popupWindow = null; - this.apiForwarder = new BackendApiForwarder(); + const apiForwarder = new BackendApiForwarder(); + apiForwarder.prepare(); - this.messageToken = yomichan.generateId(16); + this._defaultBrowserActionTitle = null; + this._isPrepared = false; + this._prepareError = false; + this._badgePrepareDelayTimer = null; + this._logErrorLevel = null; this._messageHandlers = new Map([ - ['yomichanCoreReady', {handler: this._onApiYomichanCoreReady.bind(this), async: false}], - ['optionsSchemaGet', {handler: this._onApiOptionsSchemaGet.bind(this), async: false}], - ['optionsGet', {handler: this._onApiOptionsGet.bind(this), async: false}], - ['optionsGetFull', {handler: this._onApiOptionsGetFull.bind(this), async: false}], - ['optionsSet', {handler: this._onApiOptionsSet.bind(this), async: true}], - ['optionsSave', {handler: this._onApiOptionsSave.bind(this), async: true}], - ['kanjiFind', {handler: this._onApiKanjiFind.bind(this), async: true}], - ['termsFind', {handler: this._onApiTermsFind.bind(this), async: true}], - ['textParse', {handler: this._onApiTextParse.bind(this), async: true}], - ['definitionAdd', {handler: this._onApiDefinitionAdd.bind(this), async: true}], - ['definitionsAddable', {handler: this._onApiDefinitionsAddable.bind(this), async: true}], - ['noteView', {handler: this._onApiNoteView.bind(this), async: true}], - ['templateRender', {handler: this._onApiTemplateRender.bind(this), async: true}], - ['commandExec', {handler: this._onApiCommandExec.bind(this), async: false}], - ['audioGetUri', {handler: this._onApiAudioGetUri.bind(this), async: true}], - ['screenshotGet', {handler: this._onApiScreenshotGet.bind(this), async: true}], - ['broadcastTab', {handler: this._onApiBroadcastTab.bind(this), async: false}], - ['frameInformationGet', {handler: this._onApiFrameInformationGet.bind(this), async: true}], - ['injectStylesheet', {handler: this._onApiInjectStylesheet.bind(this), async: true}], - ['getEnvironmentInfo', {handler: this._onApiGetEnvironmentInfo.bind(this), async: true}], - ['clipboardGet', {handler: this._onApiClipboardGet.bind(this), async: true}], - ['getDisplayTemplatesHtml', {handler: this._onApiGetDisplayTemplatesHtml.bind(this), async: true}], - ['getQueryParserTemplatesHtml', {handler: this._onApiGetQueryParserTemplatesHtml.bind(this), async: true}], - ['getZoom', {handler: this._onApiGetZoom.bind(this), async: true}], - ['getMessageToken', {handler: this._onApiGetMessageToken.bind(this), async: false}], - ['getDefaultAnkiFieldTemplates', {handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this), async: false}] + ['yomichanCoreReady', {async: false, contentScript: true, handler: this._onApiYomichanCoreReady.bind(this)}], + ['optionsSchemaGet', {async: false, contentScript: true, handler: this._onApiOptionsSchemaGet.bind(this)}], + ['optionsGet', {async: false, contentScript: true, handler: this._onApiOptionsGet.bind(this)}], + ['optionsGetFull', {async: false, contentScript: true, handler: this._onApiOptionsGetFull.bind(this)}], + ['optionsSave', {async: true, contentScript: true, handler: this._onApiOptionsSave.bind(this)}], + ['kanjiFind', {async: true, contentScript: true, handler: this._onApiKanjiFind.bind(this)}], + ['termsFind', {async: true, contentScript: true, handler: this._onApiTermsFind.bind(this)}], + ['textParse', {async: true, contentScript: true, handler: this._onApiTextParse.bind(this)}], + ['definitionAdd', {async: true, contentScript: true, handler: this._onApiDefinitionAdd.bind(this)}], + ['definitionsAddable', {async: true, contentScript: true, handler: this._onApiDefinitionsAddable.bind(this)}], + ['noteView', {async: true, contentScript: true, handler: this._onApiNoteView.bind(this)}], + ['templateRender', {async: true, contentScript: true, handler: this._onApiTemplateRender.bind(this)}], + ['commandExec', {async: false, contentScript: true, handler: this._onApiCommandExec.bind(this)}], + ['audioGetUri', {async: true, contentScript: true, handler: this._onApiAudioGetUri.bind(this)}], + ['screenshotGet', {async: true, contentScript: true, handler: this._onApiScreenshotGet.bind(this)}], + ['sendMessageToFrame', {async: false, contentScript: true, handler: this._onApiSendMessageToFrame.bind(this)}], + ['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}], + ['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}], + ['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.bind(this)}], + ['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}], + ['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}], + ['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.bind(this)}], + ['getQueryParserTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetQueryParserTemplatesHtml.bind(this)}], + ['getZoom', {async: true, contentScript: true, handler: this._onApiGetZoom.bind(this)}], + ['getDefaultAnkiFieldTemplates', {async: false, contentScript: true, handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this)}], + ['getAnkiDeckNames', {async: true, contentScript: false, handler: this._onApiGetAnkiDeckNames.bind(this)}], + ['getAnkiModelNames', {async: true, contentScript: false, handler: this._onApiGetAnkiModelNames.bind(this)}], + ['getAnkiModelFieldNames', {async: true, contentScript: false, handler: this._onApiGetAnkiModelFieldNames.bind(this)}], + ['getDictionaryInfo', {async: true, contentScript: false, handler: this._onApiGetDictionaryInfo.bind(this)}], + ['getDictionaryCounts', {async: true, contentScript: false, handler: this._onApiGetDictionaryCounts.bind(this)}], + ['purgeDatabase', {async: true, contentScript: false, handler: this._onApiPurgeDatabase.bind(this)}], + ['getMedia', {async: true, contentScript: true, handler: this._onApiGetMedia.bind(this)}], + ['log', {async: false, contentScript: true, handler: this._onApiLog.bind(this)}], + ['logIndicatorClear', {async: false, contentScript: true, handler: this._onApiLogIndicatorClear.bind(this)}], + ['createActionPort', {async: false, contentScript: true, handler: this._onApiCreateActionPort.bind(this)}], + ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}] + ]); + this._messageHandlersWithProgress = new Map([ + ['importDictionaryArchive', {async: true, contentScript: false, handler: this._onApiImportDictionaryArchive.bind(this)}], + ['deleteDictionary', {async: true, contentScript: false, handler: this._onApiDeleteDictionary.bind(this)}] ]); this._commandHandlers = new Map([ - ['search', this._onCommandSearch.bind(this)], - ['help', this._onCommandHelp.bind(this)], + ['search', this._onCommandSearch.bind(this)], + ['help', this._onCommandHelp.bind(this)], ['options', this._onCommandOptions.bind(this)], - ['toggle', this._onCommandToggle.bind(this)] + ['toggle', this._onCommandToggle.bind(this)] ]); } async prepare() { - await this.database.prepare(); - await this.translator.prepare(); - - this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET'); - this.defaultAnkiFieldTemplates = await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET'); - this.options = await optionsLoad(); try { + this._defaultBrowserActionTitle = await this._getBrowserIconTitle(); + this._badgePrepareDelayTimer = setTimeout(() => { + this._badgePrepareDelayTimer = null; + this._updateBadge(); + }, 1000); + this._updateBadge(); + + await this.environment.prepare(); + await this.database.prepare(); + await this.translator.prepare(); + + await profileConditionsDescriptorPromise; + + this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET'); + this.defaultAnkiFieldTemplates = (await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET')).trim(); + this.options = await optionsLoad(); this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, this.options); - } catch (e) { - // This shouldn't happen, but catch errors just in case of bugs - logError(e); - } - this.onOptionsUpdated('background'); + this.onOptionsUpdated('background'); - if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { - chrome.commands.onCommand.addListener(this._runCommand.bind(this)); - } - if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { - chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this)); - } - chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); + if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { + chrome.commands.onCommand.addListener(this._runCommand.bind(this)); + } + if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { + chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this)); + } + chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); + + const options = this.getOptions(this.optionsContext); + if (options.general.showGuide) { + chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')}); + } - this.isPrepared = true; + this.clipboardMonitor.on('change', this._onClipboardText.bind(this)); - const options = this.getOptions(this.optionsContext); - if (options.general.showGuide) { - chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')}); - } + this._sendMessageAllTabs('backendPrepared'); + const callback = () => this.checkLastError(chrome.runtime.lastError); + chrome.runtime.sendMessage({action: 'backendPrepared'}, callback); - this.clipboardMonitor.on('change', this._onClipboardText.bind(this)); + this._isPrepared = true; + } catch (e) { + this._prepareError = true; + yomichan.logError(e); + throw e; + } finally { + if (this._badgePrepareDelayTimer !== null) { + clearTimeout(this._badgePrepareDelayTimer); + this._badgePrepareDelayTimer = null; + } - this._sendMessageAllTabs('backendPrepared'); - const callback = () => this.checkLastError(chrome.runtime.lastError); - chrome.runtime.sendMessage({action: 'backendPrepared'}, callback); + this._updateBadge(); + } + } + + isPrepared() { + return this._isPrepared; } _sendMessageAllTabs(action, params={}) { @@ -167,9 +212,13 @@ class Backend { const messageHandler = this._messageHandlers.get(action); if (typeof messageHandler === 'undefined') { return false; } - const {handler, async} = messageHandler; + const {handler, async, contentScript} = messageHandler; try { + if (!contentScript) { + this._validatePrivilegedMessageSender(sender); + } + const promiseOrResult = handler(params, sender); if (async) { promiseOrResult.then( @@ -198,17 +247,10 @@ class Backend { applyOptions() { const options = this.getOptions(this.optionsContext); - if (!options.general.enable) { - this.setExtensionBadgeBackgroundColor('#555555'); - this.setExtensionBadgeText('off'); - } else if (!dictConfigured(options)) { - this.setExtensionBadgeBackgroundColor('#f0ad4e'); - this.setExtensionBadgeText('!'); - } else { - this.setExtensionBadgeText(''); - } + this._updateBadge(); - this.anki = options.anki.enable ? new AnkiConnect(options.anki.server) : new AnkiNull(); + this.anki.setServer(options.anki.server); + this.anki.setEnabled(options.anki.enable); if (options.parsing.enableMecabParser) { this.mecab.startListener(); @@ -227,8 +269,9 @@ class Backend { return this.optionsSchema; } - getFullOptions() { - return this.options; + getFullOptions(useSchema=false) { + const options = this.options; + return useSchema ? JsonSchema.createProxy(options, this.optionsSchema) : options; } setFullOptions(options) { @@ -236,25 +279,26 @@ class Backend { this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options)); } catch (e) { // This shouldn't happen, but catch errors just in case of bugs - logError(e); + yomichan.logError(e); } } - getOptions(optionsContext) { - return this.getProfile(optionsContext).options; + getOptions(optionsContext, useSchema=false) { + return this.getProfile(optionsContext, useSchema).options; } - getProfile(optionsContext) { - const profiles = this.options.profiles; + getProfile(optionsContext, useSchema=false) { + const options = this.getFullOptions(useSchema); + const profiles = options.profiles; if (typeof optionsContext.index === 'number') { return profiles[optionsContext.index]; } - const profile = this.getProfileFromContext(optionsContext); - return profile !== null ? profile : this.options.profiles[this.options.profileCurrent]; + const profile = this.getProfileFromContext(options, optionsContext); + return profile !== null ? profile : options.profiles[options.profileCurrent]; } - getProfileFromContext(optionsContext) { - for (const profile of this.options.profiles) { + getProfileFromContext(options, optionsContext) { + for (const profile of options.profiles) { const conditionGroups = profile.conditionGroups; if (conditionGroups.length > 0 && Backend.testConditionGroups(conditionGroups, optionsContext)) { return profile; @@ -285,18 +329,6 @@ class Backend { return true; } - setExtensionBadgeBackgroundColor(color) { - if (typeof chrome.browserAction.setBadgeBackgroundColor === 'function') { - chrome.browserAction.setBadgeBackgroundColor({color}); - } - } - - setExtensionBadgeText(text) { - if (typeof chrome.browserAction.setBadgeText === 'function') { - chrome.browserAction.setBadgeText({text}); - } - } - checkLastError() { // NOP } @@ -394,46 +426,6 @@ class Backend { return this.getFullOptions(); } - async _onApiOptionsSet({changedOptions, optionsContext, source}) { - const options = this.getOptions(optionsContext); - - function getValuePaths(obj) { - const valuePaths = []; - const nodes = [{obj, path: []}]; - while (nodes.length > 0) { - const node = nodes.pop(); - for (const key of Object.keys(node.obj)) { - const path = node.path.concat(key); - const obj2 = node.obj[key]; - if (obj2 !== null && typeof obj2 === 'object') { - nodes.unshift({obj: obj2, path}); - } else { - valuePaths.push([obj2, path]); - } - } - } - return valuePaths; - } - - function modifyOption(path, value) { - let pivot = options; - for (const key of path.slice(0, -1)) { - if (!hasOwn(pivot, key)) { - return false; - } - pivot = pivot[key]; - } - pivot[path[path.length - 1]] = value; - return true; - } - - for (const [value, path] of getValuePaths(changedOptions)) { - modifyOption(path, value); - } - - await this._onApiOptionsSave({source}); - } - async _onApiOptionsSave({source}) { const options = this.getFullOptions(); await optionsSave(options); @@ -484,14 +476,15 @@ class Backend { async _onApiDefinitionAdd({definition, mode, context, details, optionsContext}) { const options = this.getOptions(optionsContext); - const templates = this.defaultAnkiFieldTemplates; + const templates = this._getTemplates(options); if (mode !== 'kanji') { + const {customSourceUrl} = options.audio; await this.ankiNoteBuilder.injectAudio( definition, options.anki.terms.fields, options.audio.sources, - optionsContext + customSourceUrl ); } @@ -499,8 +492,7 @@ class Backend { await this.ankiNoteBuilder.injectScreenshot( definition, options.anki.terms.fields, - details.screenshot, - this.anki + details.screenshot ); } @@ -510,7 +502,7 @@ class Backend { async _onApiDefinitionsAddable({definitions, modes, context, optionsContext}) { const options = this.getOptions(optionsContext); - const templates = this.defaultAnkiFieldTemplates; + const templates = this._getTemplates(options); const states = []; try { @@ -540,7 +532,7 @@ class Backend { } if (cannotAdd.length > 0) { - const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0])); + const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0]), options.anki.duplicateScope); for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) { const noteIds = noteIdsArray[i]; if (noteIds.length > 0) { @@ -567,9 +559,8 @@ class Backend { return this._runCommand(command, params); } - async _onApiAudioGetUri({definition, source, optionsContext}) { - const options = this.getOptions(optionsContext); - return await this.audioUriBuilder.getUri(definition, source, options); + async _onApiAudioGetUri({definition, source, details}) { + return await this.audioUriBuilder.getUri(definition, source, details); } _onApiScreenshotGet({options}, sender) { @@ -583,6 +574,17 @@ class Backend { }); } + _onApiSendMessageToFrame({frameId, action, params}, sender) { + if (!(sender && sender.tab)) { + return false; + } + + const tabId = sender.tab.id; + const callback = () => this.checkLastError(chrome.runtime.lastError); + chrome.tabs.sendMessage(tabId, {action, params}, {frameId}, callback); + return true; + } + _onApiBroadcastTab({action, params}, sender) { if (!(sender && sender.tab)) { return false; @@ -639,15 +641,8 @@ class Backend { }); } - async _onApiGetEnvironmentInfo() { - const browser = await Backend._getBrowser(); - const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); - return { - browser, - platform: { - os: platform.os - } - }; + _onApiGetEnvironmentInfo() { + return this.environment.getInfo(); } async _onApiClipboardGet() { @@ -663,7 +658,7 @@ class Backend { being an extension with clipboard permissions. It effectively asks for the non-extension permission for clipboard access. */ - const browser = await Backend._getBrowser(); + const {browser} = this.environment.getInfo(); if (browser === 'firefox' || browser === 'firefox-mobile') { return await navigator.clipboard.readText(); } else { @@ -714,16 +709,165 @@ class Backend { }); } - _onApiGetMessageToken() { - return this.messageToken; - } - _onApiGetDefaultAnkiFieldTemplates() { return this.defaultAnkiFieldTemplates; } + async _onApiGetAnkiDeckNames() { + return await this.anki.getDeckNames(); + } + + async _onApiGetAnkiModelNames() { + return await this.anki.getModelNames(); + } + + async _onApiGetAnkiModelFieldNames({modelName}) { + return await this.anki.getModelFieldNames(modelName); + } + + async _onApiGetDictionaryInfo() { + return await this.translator.database.getDictionaryInfo(); + } + + async _onApiGetDictionaryCounts({dictionaryNames, getTotal}) { + return await this.translator.database.getDictionaryCounts(dictionaryNames, getTotal); + } + + async _onApiPurgeDatabase() { + this.translator.clearDatabaseCaches(); + await this.database.purge(); + } + + async _onApiGetMedia({targets}) { + return await this.database.getMedia(targets); + } + + _onApiLog({error, level, context}) { + yomichan.log(jsonToError(error), level, context); + + const levelValue = this._getErrorLevelValue(level); + if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } + + this._logErrorLevel = level; + this._updateBadge(); + } + + _onApiLogIndicatorClear() { + if (this._logErrorLevel === null) { return; } + this._logErrorLevel = null; + this._updateBadge(); + } + + _onApiCreateActionPort(params, sender) { + if (!sender || !sender.tab) { throw new Error('Invalid sender'); } + const tabId = sender.tab.id; + if (typeof tabId !== 'number') { throw new Error('Sender has invalid tab ID'); } + + const frameId = sender.frameId; + const id = yomichan.generateId(16); + const portName = `action-port-${id}`; + + const port = chrome.tabs.connect(tabId, {name: portName, frameId}); + try { + this._createActionListenerPort(port, sender, this._messageHandlersWithProgress); + } catch (e) { + port.disconnect(); + throw e; + } + + return portName; + } + + async _onApiImportDictionaryArchive({archiveContent, details}, sender, onProgress) { + return await this.dictionaryImporter.import(this.database, archiveContent, details, onProgress); + } + + async _onApiDeleteDictionary({dictionaryName}, sender, onProgress) { + this.translator.clearDatabaseCaches(); + await this.database.deleteDictionary(dictionaryName, {rate: 1000}, onProgress); + } + + async _onApiModifySettings({targets, source}) { + const results = []; + for (const target of targets) { + try { + this._modifySetting(target); + results.push({result: true}); + } catch (e) { + results.push({error: errorToJson(e)}); + } + } + await this._onApiOptionsSave({source}); + return results; + } + // Command handlers + _createActionListenerPort(port, sender, handlers) { + let hasStarted = false; + + const onProgress = (...data) => { + try { + if (port === null) { return; } + port.postMessage({type: 'progress', data}); + } catch (e) { + // NOP + } + }; + + const onMessage = async ({action, params}) => { + if (hasStarted) { return; } + hasStarted = true; + port.onMessage.removeListener(onMessage); + + try { + port.postMessage({type: 'ack'}); + + const messageHandler = handlers.get(action); + if (typeof messageHandler === 'undefined') { + throw new Error('Invalid action'); + } + const {handler, async, contentScript} = messageHandler; + + if (!contentScript) { + this._validatePrivilegedMessageSender(sender); + } + + const promiseOrResult = handler(params, sender, onProgress); + const result = async ? await promiseOrResult : promiseOrResult; + port.postMessage({type: 'complete', data: result}); + } catch (e) { + if (port !== null) { + port.postMessage({type: 'error', data: errorToJson(e)}); + } + cleanup(); + } + }; + + const cleanup = () => { + if (port === null) { return; } + if (!hasStarted) { + port.onMessage.removeListener(onMessage); + } + port.onDisconnect.removeListener(cleanup); + port = null; + handlers = null; + }; + + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(cleanup); + } + + _getErrorLevelValue(errorLevel) { + switch (errorLevel) { + case 'info': return 0; + case 'debug': return 0; + case 'warn': return 1; + case 'error': return 2; + default: return 0; + } + } + async _onCommandSearch(params) { const {mode='existingOrNewTab', query} = params || {}; @@ -748,7 +892,9 @@ class Backend { await Backend._focusTab(tab); if (queryParams.query) { await new Promise((resolve) => chrome.tabs.sendMessage( - tab.id, {action: 'searchQueryUpdate', params: {text: queryParams.query}}, resolve + tab.id, + {action: 'searchQueryUpdate', params: {text: queryParams.query}}, + resolve )); } return true; @@ -818,20 +964,163 @@ class Backend { // Utilities - async _getAudioUri(definition, source, details) { - let optionsContext = (typeof details === 'object' && details !== null ? details.optionsContext : null); - if (!(typeof optionsContext === 'object' && optionsContext !== null)) { - optionsContext = this.optionsContext; + _getModifySettingObject(target) { + const scope = target.scope; + switch (scope) { + case 'profile': + if (!isObject(target.optionsContext)) { throw new Error('Invalid optionsContext'); } + return this.getOptions(target.optionsContext, true); + case 'global': + return this.getFullOptions(true); + default: + throw new Error(`Invalid scope: ${scope}`); } + } - const options = this.getOptions(optionsContext); - return await this.audioUriBuilder.getUri(definition, source, options); + async _modifySetting(target) { + const options = this._getModifySettingObject(target); + const accessor = new ObjectPropertyAccessor(options); + const action = target.action; + switch (action) { + case 'set': + { + const {path, value} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + accessor.set(ObjectPropertyAccessor.getPathArray(path), value); + } + break; + case 'delete': + { + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + accessor.delete(ObjectPropertyAccessor.getPathArray(path)); + } + break; + case 'swap': + { + const {path1, path2} = target; + if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } + if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } + accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); + } + break; + case 'splice': + { + const {path, start, deleteCount, items} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } + if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } + if (!Array.isArray(items)) { throw new Error('Invalid items'); } + const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); + if (!Array.isArray(array)) { throw new Error('Invalid target type'); } + array.splice(start, deleteCount, ...items); + } + break; + default: + throw new Error(`Unknown action: ${action}`); + } + } + + _validatePrivilegedMessageSender(sender) { + const url = sender.url; + if (!(typeof url === 'string' && yomichan.isExtensionUrl(url))) { + throw new Error('Invalid message sender'); + } + } + + _getBrowserIconTitle() { + return ( + isObject(chrome.browserAction) && + typeof chrome.browserAction.getTitle === 'function' ? + new Promise((resolve) => chrome.browserAction.getTitle({}, resolve)) : + Promise.resolve('') + ); + } + + _updateBadge() { + let title = this._defaultBrowserActionTitle; + if (title === null || !isObject(chrome.browserAction)) { + // Not ready or invalid + return; + } + + let text = ''; + let color = null; + let status = null; + + if (this._logErrorLevel !== null) { + switch (this._logErrorLevel) { + case 'error': + text = '!!'; + color = '#f04e4e'; + status = 'Error'; + break; + default: // 'warn' + text = '!'; + color = '#f0ad4e'; + status = 'Warning'; + break; + } + } else if (!this._isPrepared) { + if (this._prepareError) { + text = '!!'; + color = '#f04e4e'; + status = 'Error'; + } else if (this._badgePrepareDelayTimer === null) { + text = '...'; + color = '#f0ad4e'; + status = 'Loading'; + } + } else if (!this._anyOptionsMatches((options) => options.general.enable)) { + text = 'off'; + color = '#555555'; + status = 'Disabled'; + } else if (!this._anyOptionsMatches((options) => this._isAnyDictionaryEnabled(options))) { + text = '!'; + color = '#f0ad4e'; + status = 'No dictionaries installed'; + } + + if (color !== null && typeof chrome.browserAction.setBadgeBackgroundColor === 'function') { + chrome.browserAction.setBadgeBackgroundColor({color}); + } + if (text !== null && typeof chrome.browserAction.setBadgeText === 'function') { + chrome.browserAction.setBadgeText({text}); + } + if (typeof chrome.browserAction.setTitle === 'function') { + if (status !== null) { + title = `${title} - ${status}`; + } + chrome.browserAction.setTitle({title}); + } + } + + _isAnyDictionaryEnabled(options) { + for (const {enabled} of Object.values(options.dictionaries)) { + if (enabled) { + return true; + } + } + return false; + } + + _anyOptionsMatches(predicate) { + for (const {options} of this.options.profiles) { + const value = predicate(options); + if (value) { return value; } + } + return false; } async _renderTemplate(template, data) { return handlebarsRenderDynamic(template, data); } + _getTemplates(options) { + const templates = options.anki.fieldTemplates; + return typeof templates === 'string' ? templates : this.defaultAnkiFieldTemplates; + } + static _getTabUrl(tab) { return new Promise((resolve) => { chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => { @@ -921,26 +1210,4 @@ class Backend { // Edge throws exception for no reason here. } } - - static async _getBrowser() { - if (EXTENSION_IS_BROWSER_EDGE) { - return 'edge'; - } - if (typeof browser !== 'undefined') { - try { - const info = await browser.runtime.getBrowserInfo(); - if (info.name === 'Fennec') { - return 'firefox-mobile'; - } - } catch (e) { - // NOP - } - return 'firefox'; - } else { - return 'chrome'; - } - } } - -window.yomichanBackend = new Backend(); -window.yomichanBackend.prepare(); diff --git a/ext/bg/js/background-main.js b/ext/bg/js/background-main.js new file mode 100644 index 00000000..24117f4e --- /dev/null +++ b/ext/bg/js/background-main.js @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2020 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 + * Backend + */ + +(async () => { + window.yomichanBackend = new Backend(); + await window.yomichanBackend.prepare(); +})(); diff --git a/ext/bg/js/conditions.js b/ext/bg/js/conditions.js index eb9582df..3f3c0a45 100644 --- a/ext/bg/js/conditions.js +++ b/ext/bg/js/conditions.js @@ -32,7 +32,15 @@ function conditionsValidateOptionValue(object, value) { return value; } -function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue) { +function conditionsValidateOptionInputValue(object, value) { + if (hasOwn(object, 'transformInput')) { + return object.transformInput(value); + } + + return null; +} + +function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue, isInput) { if (!hasOwn(descriptors, type)) { throw new Error('Invalid type'); } @@ -44,13 +52,34 @@ function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue const operatorDescriptor = conditionDescriptor.operators[operator]; - let transformedValue = conditionsValidateOptionValue(conditionDescriptor, optionValue); - transformedValue = conditionsValidateOptionValue(operatorDescriptor, transformedValue); + const descriptorArray = [conditionDescriptor, operatorDescriptor]; + + let transformedValue = optionValue; + + let inputTransformedValue = null; + if (isInput) { + for (const descriptor of descriptorArray) { + let value = inputTransformedValue !== null ? inputTransformedValue : transformedValue; + value = conditionsValidateOptionInputValue(descriptor, value); + if (value !== null) { + inputTransformedValue = value; + } + } + + if (inputTransformedValue !== null) { + transformedValue = inputTransformedValue; + } + } + + for (const descriptor of descriptorArray) { + transformedValue = conditionsValidateOptionValue(descriptor, transformedValue); + } if (hasOwn(operatorDescriptor, 'transformReverse')) { transformedValue = operatorDescriptor.transformReverse(transformedValue); } - return transformedValue; + + return [transformedValue, inputTransformedValue]; } function conditionsTestValueThrowing(descriptors, type, operator, optionValue, value) { diff --git a/ext/bg/js/context.js b/ext/bg/js/context-main.js index e3d4ad4a..dbba0272 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context-main.js @@ -17,7 +17,9 @@ /* global * apiCommandExec + * apiForwardLogsToBackend * apiGetEnvironmentInfo + * apiLogIndicatorClear * apiOptionsGet */ @@ -51,9 +53,12 @@ function setupButtonEvents(selector, command, url) { } } -window.addEventListener('DOMContentLoaded', async () => { +async function mainInner() { + apiForwardLogsToBackend(); await yomichan.prepare(); + await apiLogIndicatorClear(); + showExtensionInfo(); apiGetEnvironmentInfo().then(({browser}) => { @@ -86,4 +91,8 @@ window.addEventListener('DOMContentLoaded', async () => { } }, 10); }); -}); +} + +(async () => { + window.addEventListener('DOMContentLoaded', mainInner, false); +})(); diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index 260c815a..930cd0d0 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -33,7 +33,7 @@ class Database { } try { - this.db = await Database._open('dict', 5, (db, transaction, oldVersion) => { + this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => { Database._upgrade(db, transaction, oldVersion, [ { version: 2, @@ -90,12 +90,21 @@ class Database { indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] } } + }, + { + version: 6, + stores: { + media: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'path'] + } + } } ]); }); return true; } catch (e) { - logError(e); + yomichan.logError(e); return false; } } @@ -120,7 +129,7 @@ class Database { await this.prepare(); } - async deleteDictionary(dictionaryName, onProgress, progressSettings) { + async deleteDictionary(dictionaryName, progressSettings, onProgress) { this._validate(); const targets = [ @@ -268,6 +277,34 @@ class Database { return result; } + async getMedia(targets) { + this._validate(); + + const count = targets.length; + const promises = []; + const results = new Array(count).fill(null); + const createResult = Database._createMedia; + const processRow = (row, [index, dictionaryName]) => { + if (row.dictionary === dictionaryName) { + results[index] = createResult(row, index); + } + }; + + const transaction = this.db.transaction(['media'], 'readonly'); + const objectStore = transaction.objectStore('media'); + const index = objectStore.index('path'); + + for (let i = 0; i < count; ++i) { + const {path, dictionaryName} = targets[i]; + const only = IDBKeyRange.only(path); + promises.push(Database._getAll(index, only, [i, dictionaryName], processRow)); + } + + await Promise.all(promises); + + return results; + } + async getDictionaryInfo() { this._validate(); @@ -432,6 +469,10 @@ class Database { return {character, mode, data, dictionary, index}; } + static _createMedia(row, index) { + return Object.assign({}, row, {index}); + } + static _getAll(dbIndex, query, context, processRow) { const fn = typeof dbIndex.getAll === 'function' ? Database._getAllFast : Database._getAllUsingCursor; return fn(dbIndex, query, context, processRow); diff --git a/ext/bg/js/dictionary-importer.js b/ext/bg/js/dictionary-importer.js index bf6809ec..10e30cec 100644 --- a/ext/bg/js/dictionary-importer.js +++ b/ext/bg/js/dictionary-importer.js @@ -18,6 +18,7 @@ /* global * JSZip * JsonSchema + * mediaUtility * requestJson */ @@ -26,7 +27,7 @@ class DictionaryImporter { this._schemas = new Map(); } - async import(database, archiveSource, onProgress, details) { + async import(database, archiveSource, details, onProgress) { if (!database) { throw new Error('Invalid database'); } @@ -148,6 +149,22 @@ class DictionaryImporter { } } + // Extended data support + const extendedDataContext = { + archive, + media: new Map() + }; + for (const entry of termList) { + const glossaryList = entry.glossary; + for (let i = 0, ii = glossaryList.length; i < ii; ++i) { + const glossary = glossaryList[i]; + if (typeof glossary !== 'object' || glossary === null) { continue; } + glossaryList[i] = await this._formatDictionaryTermGlossaryObject(glossary, extendedDataContext, entry); + } + } + + const media = [...extendedDataContext.media.values()]; + // Add dictionary const summary = this._createSummary(dictionaryTitle, version, index, {prefixWildcardsSupported}); @@ -188,6 +205,7 @@ class DictionaryImporter { await bulkAdd('kanji', kanjiList); await bulkAdd('kanjiMeta', kanjiMetaList); await bulkAdd('tagMeta', tagList); + await bulkAdd('media', media); return {result: summary, errors}; } @@ -275,4 +293,76 @@ class DictionaryImporter { return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank]; } + + async _formatDictionaryTermGlossaryObject(data, context, entry) { + switch (data.type) { + case 'text': + return data.text; + case 'image': + return await this._formatDictionaryTermGlossaryImage(data, context, entry); + default: + throw new Error(`Unhandled data type: ${data.type}`); + } + } + + async _formatDictionaryTermGlossaryImage(data, context, entry) { + const dictionary = entry.dictionary; + const {path, width: preferredWidth, height: preferredHeight, title, description, pixelated} = data; + if (context.media.has(path)) { + // Already exists + return data; + } + + let errorSource = entry.expression; + if (entry.reading.length > 0) { + errorSource += ` (${entry.reading});`; + } + + const file = context.archive.file(path); + if (file === null) { + throw new Error(`Could not find image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + const content = await file.async('base64'); + const mediaType = mediaUtility.getImageMediaTypeFromFileName(path); + if (mediaType === null) { + throw new Error(`Could not determine media type for image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + let image; + try { + image = await mediaUtility.loadImageBase64(mediaType, content); + } catch (e) { + throw new Error(`Could not load image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + const width = image.naturalWidth; + const height = image.naturalHeight; + + // Create image data + const mediaData = { + dictionary, + path, + mediaType, + width, + height, + content + }; + context.media.set(path, mediaData); + + // Create new data + const newData = { + type: 'image', + path, + width, + height + }; + if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; } + if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; } + if (typeof title === 'string') { newData.title = title; } + if (typeof description === 'string') { newData.description = description; } + if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; } + + return newData; + } } diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js index 860acb14..822174e2 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.js @@ -123,6 +123,26 @@ function handlebarsRegexMatch(...args) { return value; } +function handlebarsMergeTags(object, isGroupMode, isMergeMode) { + const tagSources = []; + if (isGroupMode || isMergeMode) { + for (const definition of object.definitions) { + tagSources.push(definition.definitionTags); + } + } else { + tagSources.push(object.definitionTags); + } + + const tags = new Set(); + for (const tagSource of tagSources) { + for (const tag of tagSource) { + tags.add(tag.name); + } + } + + return [...tags].join(', '); +} + function handlebarsRegisterHelpers() { if (Handlebars.partials !== Handlebars.templates) { Handlebars.partials = Handlebars.templates; @@ -134,6 +154,7 @@ function handlebarsRegisterHelpers() { Handlebars.registerHelper('sanitizeCssClass', handlebarsSanitizeCssClass); Handlebars.registerHelper('regexReplace', handlebarsRegexReplace); Handlebars.registerHelper('regexMatch', handlebarsRegexMatch); + Handlebars.registerHelper('mergeTags', handlebarsMergeTags); } } diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js deleted file mode 100644 index ac81acb5..00000000 --- a/ext/bg/js/japanese.js +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright (C) 2016-2020 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 - * jp - * wanakana - */ - -(() => { - const HALFWIDTH_KATAKANA_MAPPING = new Map([ - ['ヲ', 'ヲヺ-'], - ['ァ', 'ァ--'], - ['ィ', 'ィ--'], - ['ゥ', 'ゥ--'], - ['ェ', 'ェ--'], - ['ォ', 'ォ--'], - ['ャ', 'ャ--'], - ['ュ', 'ュ--'], - ['ョ', 'ョ--'], - ['ッ', 'ッ--'], - ['ー', 'ー--'], - ['ア', 'ア--'], - ['イ', 'イ--'], - ['ウ', 'ウヴ-'], - ['エ', 'エ--'], - ['オ', 'オ--'], - ['カ', 'カガ-'], - ['キ', 'キギ-'], - ['ク', 'クグ-'], - ['ケ', 'ケゲ-'], - ['コ', 'コゴ-'], - ['サ', 'サザ-'], - ['シ', 'シジ-'], - ['ス', 'スズ-'], - ['セ', 'セゼ-'], - ['ソ', 'ソゾ-'], - ['タ', 'タダ-'], - ['チ', 'チヂ-'], - ['ツ', 'ツヅ-'], - ['テ', 'テデ-'], - ['ト', 'トド-'], - ['ナ', 'ナ--'], - ['ニ', 'ニ--'], - ['ヌ', 'ヌ--'], - ['ネ', 'ネ--'], - ['ノ', 'ノ--'], - ['ハ', 'ハバパ'], - ['ヒ', 'ヒビピ'], - ['フ', 'フブプ'], - ['ヘ', 'ヘベペ'], - ['ホ', 'ホボポ'], - ['マ', 'マ--'], - ['ミ', 'ミ--'], - ['ム', 'ム--'], - ['メ', 'メ--'], - ['モ', 'モ--'], - ['ヤ', 'ヤ--'], - ['ユ', 'ユ--'], - ['ヨ', 'ヨ--'], - ['ラ', 'ラ--'], - ['リ', 'リ--'], - ['ル', 'ル--'], - ['レ', 'レ--'], - ['ロ', 'ロ--'], - ['ワ', 'ワ--'], - ['ン', 'ン--'] - ]); - - const ITERATION_MARK_CODE_POINT = 0x3005; - - const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063; - const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3; - const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc; - - // Existing functions - - const isCodePointKanji = jp.isCodePointKanji; - const isStringEntirelyKana = jp.isStringEntirelyKana; - - - // Conversion functions - - function convertKatakanaToHiragana(text) { - let result = ''; - for (const c of text) { - if (wanakana.isKatakana(c)) { - result += wanakana.toHiragana(c); - } else { - result += c; - } - } - - return result; - } - - function convertHiraganaToKatakana(text) { - let result = ''; - for (const c of text) { - if (wanakana.isHiragana(c)) { - result += wanakana.toKatakana(c); - } else { - result += c; - } - } - - return result; - } - - function convertToRomaji(text) { - return wanakana.toRomaji(text); - } - - function convertReading(expression, reading, readingMode) { - switch (readingMode) { - case 'hiragana': - return convertKatakanaToHiragana(reading); - case 'katakana': - return convertHiraganaToKatakana(reading); - case 'romaji': - if (reading) { - return convertToRomaji(reading); - } else { - if (isStringEntirelyKana(expression)) { - return convertToRomaji(expression); - } - } - return reading; - case 'none': - return ''; - default: - return reading; - } - } - - function 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; - } - - function 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; - } - - function 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 += convertAlphabeticPartToKana(part, sourceMap, result.length); - part = ''; - } - result += char; - continue; - } - part += String.fromCodePoint(c); - } - - if (part.length > 0) { - result += convertAlphabeticPartToKana(part, sourceMap, result.length); - } - return result; - } - - function convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) { - 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; - } - - - // Furigana distribution - - function distributeFurigana(expression, reading) { - const fallback = [{furigana: reading, text: expression}]; - if (!reading) { - return fallback; - } - - let isAmbiguous = false; - const segmentize = (reading2, groups) => { - if (groups.length === 0 || isAmbiguous) { - return []; - } - - const group = groups[0]; - if (group.mode === 'kana') { - if (convertKatakanaToHiragana(reading2).startsWith(convertKatakanaToHiragana(group.text))) { - const readingLeft = reading2.substring(group.text.length); - const segs = segmentize(readingLeft, groups.splice(1)); - if (segs) { - return [{text: group.text, furigana: ''}].concat(segs); - } - } - } else { - let foundSegments = null; - for (let i = reading2.length; i >= group.text.length; --i) { - const readingUsed = reading2.substring(0, i); - const readingLeft = reading2.substring(i); - const segs = segmentize(readingLeft, groups.slice(1)); - if (segs) { - if (foundSegments !== null) { - // more than one way to segmentize the tail, mark as ambiguous - isAmbiguous = true; - return null; - } - foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs); - } - // there is only one way to segmentize the last non-kana group - if (groups.length === 1) { - break; - } - } - return foundSegments; - } - }; - - const groups = []; - let modePrev = null; - for (const c of expression) { - const codePoint = c.codePointAt(0); - const modeCurr = isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT ? 'kanji' : 'kana'; - if (modeCurr === modePrev) { - groups[groups.length - 1].text += c; - } else { - groups.push({mode: modeCurr, text: c}); - modePrev = modeCurr; - } - } - - const segments = segmentize(reading, groups); - if (segments && !isAmbiguous) { - return segments; - } - return fallback; - } - - function distributeFuriganaInflected(expression, reading, source) { - const output = []; - - let stemLength = 0; - const shortest = Math.min(source.length, expression.length); - const sourceHiragana = convertKatakanaToHiragana(source); - const expressionHiragana = 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 - ); - for (const segment of distributeFurigana(stemExpression, stemReading)) { - output.push(segment); - } - - if (stemLength !== source.length) { - output.push({text: source.substring(stemLength), furigana: ''}); - } - - return output; - } - - - // Miscellaneous - - function 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; - } - - - // Exports - - Object.assign(jp, { - convertKatakanaToHiragana, - convertHiraganaToKatakana, - convertToRomaji, - convertReading, - convertNumericToFullWidth, - convertHalfWidthKanaToFullWidth, - convertAlphabeticToKana, - distributeFurigana, - distributeFuriganaInflected, - collapseEmphaticSequences - }); -})(); diff --git a/ext/bg/js/mecab.js b/ext/bg/js/mecab.js index 597dceae..815ee860 100644 --- a/ext/bg/js/mecab.js +++ b/ext/bg/js/mecab.js @@ -24,7 +24,7 @@ class Mecab { } onError(error) { - logError(error, false); + yomichan.logError(error); } async checkVersion() { diff --git a/ext/bg/js/media-utility.js b/ext/bg/js/media-utility.js new file mode 100644 index 00000000..1f93b2b4 --- /dev/null +++ b/ext/bg/js/media-utility.js @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2020 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/>. + */ + +/** + * mediaUtility is an object containing helper methods related to media processing. + */ +const mediaUtility = (() => { + /** + * Gets the file extension of a file path. URL search queries and hash + * fragments are not handled. + * @param path The path to the file. + * @returns The file extension, including the '.', or an empty string + * if there is no file extension. + */ + function getFileNameExtension(path) { + const match = /\.[^./\\]*$/.exec(path); + return match !== null ? match[0] : ''; + } + + /** + * Gets an image file's media type using a file path. + * @param path The path to the file. + * @returns The media type string if it can be determined from the file path, + * otherwise null. + */ + function getImageMediaTypeFromFileName(path) { + switch (getFileNameExtension(path).toLowerCase()) { + case '.apng': + return 'image/apng'; + case '.bmp': + return 'image/bmp'; + case '.gif': + return 'image/gif'; + case '.ico': + case '.cur': + return 'image/x-icon'; + case '.jpg': + case '.jpeg': + case '.jfif': + case '.pjpeg': + case '.pjp': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml'; + case '.tif': + case '.tiff': + return 'image/tiff'; + case '.webp': + return 'image/webp'; + default: + return null; + } + } + + /** + * Attempts to load an image using a base64 encoded content and a media type. + * @param mediaType The media type for the image content. + * @param content The binary content for the image, encoded in base64. + * @returns A Promise which resolves with an HTMLImageElement instance on + * successful load, otherwise an error is thrown. + */ + function loadImageBase64(mediaType, content) { + return new Promise((resolve, reject) => { + const image = new Image(); + const eventListeners = new EventListenerCollection(); + eventListeners.addEventListener(image, 'load', () => { + eventListeners.removeAllEventListeners(); + resolve(image); + }, false); + eventListeners.addEventListener(image, 'error', () => { + eventListeners.removeAllEventListeners(); + reject(new Error('Image failed to load')); + }, false); + image.src = `data:${mediaType};base64,${content}`; + }); + } + + return { + getImageMediaTypeFromFileName, + loadImageBase64 + }; +})(); diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index f3e5f60d..35fdde82 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -15,14 +15,23 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/* global - * utilStringHashCode - */ - /* * Generic options functions */ +function optionsGetStringHashCode(string) { + let hashCode = 0; + + if (typeof string !== 'string') { return hashCode; } + + for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) { + hashCode = ((hashCode << 5) - hashCode) + charCode; + hashCode |= 0; + } + + return hashCode; +} + function optionsGenericApplyUpdates(options, updates) { const targetVersion = updates.length; const currentVersion = options.version; @@ -63,12 +72,12 @@ const profileOptionsVersionUpdates = [ options.anki.fieldTemplates = null; }, (options) => { - if (utilStringHashCode(options.anki.fieldTemplates) === 1285806040) { + if (optionsGetStringHashCode(options.anki.fieldTemplates) === 1285806040) { options.anki.fieldTemplates = null; } }, (options) => { - if (utilStringHashCode(options.anki.fieldTemplates) === -250091611) { + if (optionsGetStringHashCode(options.anki.fieldTemplates) === -250091611) { options.anki.fieldTemplates = null; } }, @@ -87,7 +96,7 @@ const profileOptionsVersionUpdates = [ (options) => { // Version 12 changes: // The preferred default value of options.anki.fieldTemplates has been changed to null. - if (utilStringHashCode(options.anki.fieldTemplates) === 1444379824) { + if (optionsGetStringHashCode(options.anki.fieldTemplates) === 1444379824) { options.anki.fieldTemplates = null; } }, @@ -99,6 +108,37 @@ const profileOptionsVersionUpdates = [ fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}'; options.anki.fieldTemplates = fieldTemplates; } + }, + (options) => { + // Version 14 changes: + // Changed template for Anki audio and tags. + let fieldTemplates = options.anki.fieldTemplates; + if (typeof fieldTemplates !== 'string') { return; } + + const replacements = [ + [ + '{{#*inline "audio"}}{{/inline}}', + '{{#*inline "audio"}}\n {{~#if definition.audioFileName~}}\n [sound:{{definition.audioFileName}}]\n {{~/if~}}\n{{/inline}}' + ], + [ + '{{#*inline "tags"}}\n {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}\n{{/inline}}', + '{{#*inline "tags"}}\n {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}\n{{/inline}}' + ] + ]; + + for (const [pattern, replacement] of replacements) { + let replaced = false; + fieldTemplates = fieldTemplates.replace(new RegExp(escapeRegExp(pattern), 'g'), () => { + replaced = true; + return replacement; + }); + + if (!replaced) { + fieldTemplates += '\n\n' + replacement; + } + } + + options.anki.fieldTemplates = fieldTemplates; } ]; @@ -192,6 +232,7 @@ function profileOptionsCreateDefaults() { screenshot: {format: 'png', quality: 92}, terms: {deck: '', model: '', fields: {}}, kanji: {deck: '', model: '', fields: {}}, + duplicateScope: 'collection', fieldTemplates: null } }; diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js index a0710bd1..97e09f1c 100644 --- a/ext/bg/js/profile-conditions.js +++ b/ext/bg/js/profile-conditions.js @@ -15,6 +15,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/* global + * Environment + */ function _profileConditionTestDomain(urlDomain, domain) { return ( @@ -36,69 +39,140 @@ function _profileConditionTestDomainList(url, domainList) { return false; } -const profileConditionsDescriptor = { - popupLevel: { - name: 'Popup Level', - description: 'Use profile depending on the level of the popup.', - placeholder: 'Number', - type: 'number', - step: 1, - defaultValue: 0, - defaultOperator: 'equal', - transform: (optionValue) => parseInt(optionValue, 10), - transformReverse: (transformedOptionValue) => `${transformedOptionValue}`, - validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue), - operators: { - equal: { - name: '=', - test: ({depth}, optionValue) => (depth === optionValue) - }, - notEqual: { - name: '\u2260', - test: ({depth}, optionValue) => (depth !== optionValue) - }, - lessThan: { - name: '<', - test: ({depth}, optionValue) => (depth < optionValue) - }, - greaterThan: { - name: '>', - test: ({depth}, optionValue) => (depth > optionValue) - }, - lessThanOrEqual: { - name: '\u2264', - test: ({depth}, optionValue) => (depth <= optionValue) - }, - greaterThanOrEqual: { - name: '\u2265', - test: ({depth}, optionValue) => (depth >= optionValue) +let profileConditionsDescriptor = null; + +const profileConditionsDescriptorPromise = (async () => { + const environment = new Environment(); + await environment.prepare(); + + const modifiers = environment.getInfo().modifiers; + const modifierSeparator = modifiers.separator; + const modifierKeyValues = modifiers.keys.map( + ({value, name}) => ({optionValue: value, name}) + ); + + const modifierValueToName = new Map( + modifierKeyValues.map(({optionValue, name}) => [optionValue, name]) + ); + + const modifierNameToValue = new Map( + modifierKeyValues.map(({optionValue, name}) => [name, optionValue]) + ); + + profileConditionsDescriptor = { + popupLevel: { + name: 'Popup Level', + description: 'Use profile depending on the level of the popup.', + placeholder: 'Number', + type: 'number', + step: 1, + defaultValue: 0, + defaultOperator: 'equal', + transform: (optionValue) => parseInt(optionValue, 10), + transformReverse: (transformedOptionValue) => `${transformedOptionValue}`, + validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue), + operators: { + equal: { + name: '=', + test: ({depth}, optionValue) => (depth === optionValue) + }, + notEqual: { + name: '\u2260', + test: ({depth}, optionValue) => (depth !== optionValue) + }, + lessThan: { + name: '<', + test: ({depth}, optionValue) => (depth < optionValue) + }, + greaterThan: { + name: '>', + test: ({depth}, optionValue) => (depth > optionValue) + }, + lessThanOrEqual: { + name: '\u2264', + test: ({depth}, optionValue) => (depth <= optionValue) + }, + greaterThanOrEqual: { + name: '\u2265', + test: ({depth}, optionValue) => (depth >= optionValue) + } } - } - }, - url: { - name: 'URL', - description: 'Use profile depending on the URL of the current website.', - defaultOperator: 'matchDomain', - operators: { - matchDomain: { - name: 'Matches Domain', - placeholder: 'Comma separated list of domains', - defaultValue: 'example.com', - transformCache: {}, - transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0), - transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '), - validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0), - test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue) - }, - matchRegExp: { - name: 'Matches RegExp', - placeholder: 'Regular expression', - defaultValue: 'example\\.com', - transformCache: {}, - transform: (optionValue) => new RegExp(optionValue, 'i'), - transformReverse: (transformedOptionValue) => transformedOptionValue.source, - test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url)) + }, + url: { + name: 'URL', + description: 'Use profile depending on the URL of the current website.', + defaultOperator: 'matchDomain', + operators: { + matchDomain: { + name: 'Matches Domain', + placeholder: 'Comma separated list of domains', + defaultValue: 'example.com', + transformCache: {}, + transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0), + transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '), + validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0), + test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue) + }, + matchRegExp: { + name: 'Matches RegExp', + placeholder: 'Regular expression', + defaultValue: 'example\\.com', + transformCache: {}, + transform: (optionValue) => new RegExp(optionValue, 'i'), + transformReverse: (transformedOptionValue) => transformedOptionValue.source, + test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url)) + } + } + }, + modifierKeys: { + name: 'Modifier Keys', + description: 'Use profile depending on the active modifier keys.', + values: modifierKeyValues, + defaultOperator: 'are', + operators: { + are: { + name: 'are', + placeholder: 'Press one or more modifier keys here', + defaultValue: [], + type: 'keyMulti', + keySeparator: modifierSeparator, + transformInput: (optionValue) => optionValue + .split(modifierSeparator) + .filter((v) => v.length > 0) + .map((v) => modifierNameToValue.get(v)), + transformReverse: (transformedOptionValue) => transformedOptionValue + .map((v) => modifierValueToName.get(v)) + .join(modifierSeparator), + test: ({modifierKeys}, optionValue) => areSetsEqual(new Set(modifierKeys), new Set(optionValue)) + }, + areNot: { + name: 'are not', + placeholder: 'Press one or more modifier keys here', + defaultValue: [], + type: 'keyMulti', + keySeparator: modifierSeparator, + transformInput: (optionValue) => optionValue + .split(modifierSeparator) + .filter((v) => v.length > 0) + .map((v) => modifierNameToValue.get(v)), + transformReverse: (transformedOptionValue) => transformedOptionValue + .map((v) => modifierValueToName.get(v)) + .join(modifierSeparator), + test: ({modifierKeys}, optionValue) => !areSetsEqual(new Set(modifierKeys), new Set(optionValue)) + }, + include: { + name: 'include', + type: 'select', + defaultValue: 'alt', + test: ({modifierKeys}, optionValue) => modifierKeys.includes(optionValue) + }, + notInclude: { + name: 'don\'t include', + type: 'select', + defaultValue: 'alt', + test: ({modifierKeys}, optionValue) => !modifierKeys.includes(optionValue) + } } } - } -}; + }; +})(); diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-main.js index e534e771..54fa549d 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-main.js @@ -16,64 +16,46 @@ */ /* global + * DisplaySearch + * apiForwardLogsToBackend * apiOptionsGet + * dynamicLoader */ -function injectSearchFrontend() { - const scriptSrcs = [ +async function injectSearchFrontend() { + await dynamicLoader.loadScripts([ '/mixed/js/text-scanner.js', '/fg/js/frontend-api-receiver.js', '/fg/js/frame-offset-forwarder.js', '/fg/js/popup.js', - '/fg/js/popup-proxy-host.js', + '/fg/js/popup-factory.js', '/fg/js/frontend.js', - '/fg/js/frontend-initialize.js' - ]; - for (const src of scriptSrcs) { - const node = document.querySelector(`script[src='${src}']`); - if (node !== null) { continue; } - - const script = document.createElement('script'); - script.async = false; - script.src = src; - document.body.appendChild(script); - } - - const styleSrcs = [ - '/fg/css/client.css' - ]; - for (const src of styleSrcs) { - const style = document.createElement('link'); - style.rel = 'stylesheet'; - style.type = 'text/css'; - style.href = src; - document.head.appendChild(style); - } + '/fg/js/content-script-main.js' + ]); } -async function main() { +(async () => { + apiForwardLogsToBackend(); await yomichan.prepare(); + const displaySearch = new DisplaySearch(); + await displaySearch.prepare(); + let optionsApplied = false; const applyOptions = async () => { - const optionsContext = { - depth: 0, - url: window.location.href - }; + const optionsContext = {depth: 0, url: window.location.href}; const options = await apiOptionsGet(optionsContext); if (!options.scanning.enableOnSearchPage || optionsApplied) { return; } + optionsApplied = true; + yomichan.off('optionsUpdated', applyOptions); window.frontendInitializationData = {depth: 1, proxy: false, isSearchPage: true}; - injectSearchFrontend(); - - yomichan.off('optionsUpdated', applyOptions); + await injectSearchFrontend(); }; yomichan.on('optionsUpdated', applyOptions); await applyOptions(); -} - -main(); +})(); diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index eb3b681c..e1e37d1c 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -18,56 +18,76 @@ /* global * QueryParserGenerator * TextScanner - * apiOptionsSet + * apiModifySettings * apiTermsFind * apiTextParse * docSentenceExtract */ -class QueryParser extends TextScanner { +class QueryParser { constructor({getOptionsContext, setContent, setSpinnerVisible}) { - super(document.querySelector('#query-parser-content'), () => [], []); + this._options = null; + this._getOptionsContext = getOptionsContext; + this._setContent = setContent; + this._setSpinnerVisible = setSpinnerVisible; + this._parseResults = []; + this._queryParser = document.querySelector('#query-parser-content'); + this._queryParserSelect = document.querySelector('#query-parser-select-container'); + this._queryParserGenerator = new QueryParserGenerator(); + this._textScanner = new TextScanner({ + node: this._queryParser, + ignoreElements: () => [], + ignorePoint: null, + search: this._search.bind(this) + }); + } - this.getOptionsContext = getOptionsContext; - this.setContent = setContent; - this.setSpinnerVisible = setSpinnerVisible; + async prepare() { + await this._queryParserGenerator.prepare(); + this._queryParser.addEventListener('click', this._onClick.bind(this)); + } - this.parseResults = []; + setOptions(options) { + this._options = options; + this._textScanner.setOptions(options); + this._textScanner.setEnabled(true); + this._queryParser.dataset.termSpacing = `${options.parsing.termSpacing}`; + } - this.queryParser = document.querySelector('#query-parser-content'); - this.queryParserSelect = document.querySelector('#query-parser-select-container'); + async setText(text) { + this._setSpinnerVisible(true); - this.queryParserGenerator = new QueryParserGenerator(); - } + this._setPreview(text); - async prepare() { - await this.queryParserGenerator.prepare(); - } + this._parseResults = await apiTextParse(text, this._getOptionsContext()); + this._refreshSelectedParser(); + + this._renderParserSelect(); + this._renderParseResult(); - onError(error) { - logError(error, false); + this._setSpinnerVisible(false); } - onClick(e) { - super.onClick(e); - this.searchAt(e.clientX, e.clientY, 'click'); + // Private + + _onClick(e) { + this._textScanner.searchAt(e.clientX, e.clientY, 'click'); } - async onSearchSource(textSource, cause) { + async _search(textSource, cause) { if (textSource === null) { return null; } - this.setTextSourceScanLength(textSource, this.options.scanning.length); - const searchText = textSource.text(); - if (searchText.length === 0) { return; } + const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length); + if (searchText.length === 0) { return null; } - const {definitions, length} = await apiTermsFind(searchText, {}, this.getOptionsContext()); + const {definitions, length} = await apiTermsFind(searchText, {}, this._getOptionsContext()); if (definitions.length === 0) { return null; } - const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); + const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt); textSource.setEndOffset(length); - this.setContent('terms', {definitions, context: { + this._setContent('terms', {definitions, context: { focus: false, disableHistory: cause === 'mouse', sentence, @@ -77,89 +97,61 @@ class QueryParser extends TextScanner { return {definitions, type: 'terms'}; } - onParserChange(e) { - const selectedParser = e.target.value; - apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext()); - } - - getMouseEventListeners() { - return [ - [this.node, 'click', this.onClick.bind(this)], - [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)] - ]; + _onParserChange(e) { + const value = e.target.value; + apiModifySettings([{ + action: 'set', + path: 'parsing.selectedParser', + value, + scope: 'profile', + optionsContext: this._getOptionsContext() + }], 'search'); } - 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)] - ]; - } - - setOptions(options) { - super.setOptions(options); - this.queryParser.dataset.termSpacing = `${options.parsing.termSpacing}`; - } - - refreshSelectedParser() { - if (this.parseResults.length > 0) { - if (!this.getParseResult()) { - const selectedParser = this.parseResults[0].id; - apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext()); + _refreshSelectedParser() { + if (this._parseResults.length > 0) { + if (!this._getParseResult()) { + const value = this._parseResults[0].id; + apiModifySettings([{ + action: 'set', + path: 'parsing.selectedParser', + value, + scope: 'profile', + optionsContext: this._getOptionsContext() + }], 'search'); } } } - getParseResult() { - const {selectedParser} = this.options.parsing; - return this.parseResults.find((r) => r.id === selectedParser); - } - - async setText(text) { - this.setSpinnerVisible(true); - - this.setPreview(text); - - this.parseResults = await apiTextParse(text, this.getOptionsContext()); - this.refreshSelectedParser(); - - this.renderParserSelect(); - this.renderParseResult(); - - this.setSpinnerVisible(false); + _getParseResult() { + const {selectedParser} = this._options.parsing; + return this._parseResults.find((r) => r.id === selectedParser); } - setPreview(text) { + _setPreview(text) { const previewTerms = []; for (let i = 0, ii = text.length; i < ii; i += 2) { const tempText = text.substring(i, i + 2); previewTerms.push([{text: tempText, reading: ''}]); } - this.queryParser.textContent = ''; - this.queryParser.appendChild(this.queryParserGenerator.createParseResult(previewTerms, true)); + this._queryParser.textContent = ''; + this._queryParser.appendChild(this._queryParserGenerator.createParseResult(previewTerms, true)); } - renderParserSelect() { - this.queryParserSelect.textContent = ''; - if (this.parseResults.length > 1) { - const {selectedParser} = this.options.parsing; - const select = this.queryParserGenerator.createParserSelect(this.parseResults, selectedParser); - select.addEventListener('change', this.onParserChange.bind(this)); - this.queryParserSelect.appendChild(select); + _renderParserSelect() { + this._queryParserSelect.textContent = ''; + if (this._parseResults.length > 1) { + const {selectedParser} = this._options.parsing; + const select = this._queryParserGenerator.createParserSelect(this._parseResults, selectedParser); + select.addEventListener('change', this._onParserChange.bind(this)); + this._queryParserSelect.appendChild(select); } } - renderParseResult() { - const parseResult = this.getParseResult(); - this.queryParser.textContent = ''; + _renderParseResult() { + const parseResult = this._getParseResult(); + this._queryParser.textContent = ''; if (!parseResult) { return; } - this.queryParser.appendChild(this.queryParserGenerator.createParseResult(parseResult.content)); + this._queryParser.appendChild(this._queryParserGenerator.createParseResult(parseResult.content)); } } diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index 871c576b..96e8a70b 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -17,11 +17,13 @@ /* global * ClipboardMonitor + * DOM * Display * QueryParser * apiClipboardGet - * apiOptionsSet + * apiModifySettings * apiTermsFind + * wanakana */ class DisplaySearch extends Display { @@ -72,15 +74,11 @@ class DisplaySearch extends Display { ]); } - static create() { - const instance = new DisplaySearch(); - instance.prepare(); - return instance; - } - async prepare() { try { await super.prepare(); + await this.updateOptions(); + yomichan.on('optionsUpdated', () => this.updateOptions()); await this.queryParser.prepare(); const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); @@ -89,7 +87,7 @@ class DisplaySearch extends Display { if (this.options.general.enableWanakana === true) { this.wanakanaEnable.checked = true; - window.wanakana.bind(this.query); + wanakana.bind(this.query); } else { this.wanakanaEnable.checked = false; } @@ -125,10 +123,10 @@ class DisplaySearch extends Display { } onError(error) { - logError(error, true); + yomichan.logError(error); } - onSearchClear() { + onEscape() { if (this.query === null) { return; } @@ -181,7 +179,7 @@ class DisplaySearch extends Display { } onKeyDown(e) { - const key = Display.getKeyFromEvent(e); + const key = DOM.getKeyFromEvent(e); const ignoreKeys = this._onKeyDownIgnoreKeys; const activeModifierMap = new Map([ @@ -236,7 +234,7 @@ class DisplaySearch extends Display { this.setIntroVisible(!valid, animate); this.updateSearchButton(); if (valid) { - const {definitions} = await apiTermsFind(query, details, this.optionsContext); + const {definitions} = await apiTermsFind(query, details, this.getOptionsContext()); this.setContent('terms', {definitions, context: { focus: false, disableHistory: true, @@ -254,13 +252,19 @@ class DisplaySearch extends Display { } onWanakanaEnableChange(e) { - const enableWanakana = e.target.checked; - if (enableWanakana) { - window.wanakana.bind(this.query); + const value = e.target.checked; + if (value) { + wanakana.bind(this.query); } else { - window.wanakana.unbind(this.query); + wanakana.unbind(this.query); } - apiOptionsSet({general: {enableWanakana}}, this.getOptionsContext()); + apiModifySettings([{ + action: 'set', + path: 'general.enableWanakana', + value, + scope: 'profile', + optionsContext: this.getOptionsContext() + }], 'search'); } onClipboardMonitorEnableChange(e) { @@ -270,7 +274,13 @@ class DisplaySearch extends Display { (granted) => { if (granted) { this.clipboardMonitor.start(); - apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext()); + apiModifySettings([{ + action: 'set', + path: 'general.enableClipboardMonitor', + value: true, + scope: 'profile', + optionsContext: this.getOptionsContext() + }], 'search'); } else { e.target.checked = false; } @@ -278,7 +288,13 @@ class DisplaySearch extends Display { ); } else { this.clipboardMonitor.stop(); - apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext()); + apiModifySettings([{ + action: 'set', + path: 'general.enableClipboardMonitor', + value: false, + scope: 'profile', + optionsContext: this.getOptionsContext() + }], 'search'); } } @@ -298,11 +314,16 @@ class DisplaySearch extends Display { } setQuery(query) { - const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query; + const interpretedQuery = this.isWanakanaEnabled() ? wanakana.toKana(query) : query; this.query.value = interpretedQuery; this.queryParser.setText(interpretedQuery); } + async setContent(type, details) { + this.query.blur(); + await super.setContent(type, details); + } + setIntroVisible(visible, animate) { if (this.introVisible === visible) { return; @@ -376,5 +397,3 @@ class DisplaySearch extends Display { } } } - -DisplaySearch.instance = DisplaySearch.create(); diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index b32a9517..ff1277ed 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -16,13 +16,13 @@ */ /* global + * apiGetAnkiDeckNames + * apiGetAnkiModelFieldNames + * apiGetAnkiModelNames * getOptionsContext * getOptionsMutable * onFormOptionsChanged * settingsSaveOptions - * utilAnkiGetDeckNames - * utilAnkiGetModelFieldNames - * utilAnkiGetModelNames * utilBackgroundIsolate */ @@ -107,7 +107,7 @@ async function _ankiDeckAndModelPopulate(options) { const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'}; try { _ankiSpinnerShow(true); - const [deckNames, modelNames] = await Promise.all([utilAnkiGetDeckNames(), utilAnkiGetModelNames()]); + const [deckNames, modelNames] = await Promise.all([apiGetAnkiDeckNames(), apiGetAnkiModelNames()]); deckNames.sort(); modelNames.sort(); termsDeck.values = deckNames; @@ -180,7 +180,7 @@ async function _onAnkiModelChanged(e) { let fieldNames; try { const modelName = node.value; - fieldNames = await utilAnkiGetModelFieldNames(modelName); + fieldNames = await apiGetAnkiModelFieldNames(modelName); _ankiSetError(null); } catch (error) { _ankiSetError(error); diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index 3c6e126c..ac2d82f3 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -18,7 +18,6 @@ /* global * AudioSourceUI * AudioSystem - * apiAudioGetUri * getOptionsContext * getOptionsMutable * settingsSaveOptions @@ -29,10 +28,8 @@ let audioSystem = null; async function audioSettingsInitialize() { audioSystem = new AudioSystem({ - getAudioUri: async (definition, source) => { - const optionsContext = getOptionsContext(); - return await apiAudioGetUri(definition, source, optionsContext); - } + audioUriBuilder: null, + useCache: true }); const optionsContext = getOptionsContext(); @@ -115,7 +112,7 @@ function textToSpeechTest() { const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || ''; const voiceUri = document.querySelector('#text-to-speech-voice').value; - const audio = audioSystem.createTextToSpeechAudio({text, voiceUri}); + const audio = audioSystem.createTextToSpeechAudio(text, voiceUri); audio.volume = 1.0; audio.play(); } catch (e) { diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index bdfef658..faf4e592 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -133,7 +133,7 @@ async function _settingsImportSetOptionsFull(optionsFull) { } function _showSettingsImportError(error) { - logError(error); + yomichan.logError(error); document.querySelector('#settings-import-error-modal-message').textContent = `${error}`; $('#settings-import-error-modal').modal('show'); } diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index 84498b42..031689a7 100644 --- a/ext/bg/js/settings/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -16,6 +16,7 @@ */ /* global + * DOM * conditionsNormalizeOptionValue */ @@ -103,11 +104,11 @@ ConditionsUI.Container = class Container { if (hasOwn(conditionDescriptor.operators, operator)) { const operatorDescriptor = conditionDescriptor.operators[operator]; if (hasOwn(operatorDescriptor, 'defaultValue')) { - return {value: operatorDescriptor.defaultValue, fromOperator: true}; + return {value: this.isolate(operatorDescriptor.defaultValue), fromOperator: true}; } } if (hasOwn(conditionDescriptor, 'defaultValue')) { - return {value: conditionDescriptor.defaultValue, fromOperator: false}; + return {value: this.isolate(conditionDescriptor.defaultValue), fromOperator: false}; } } return {fromOperator: false}; @@ -177,7 +178,8 @@ ConditionsUI.Condition = class Condition { this.parent = parent; this.condition = condition; this.container = ConditionsUI.instantiateTemplate('#condition-template').appendTo(parent.container); - this.input = this.container.find('input'); + this.input = this.container.find('.condition-input'); + this.inputInner = null; this.typeSelect = this.container.find('.condition-type'); this.operatorSelect = this.container.find('.condition-operator'); this.removeButton = this.container.find('.condition-remove'); @@ -186,14 +188,13 @@ ConditionsUI.Condition = class Condition { this.updateOperators(); this.updateInput(); - this.input.on('change', this.onInputChanged.bind(this)); this.typeSelect.on('change', this.onConditionTypeChanged.bind(this)); this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this)); this.removeButton.on('click', this.onRemoveClicked.bind(this)); } cleanup() { - this.input.off('change'); + this.inputInner.off('change'); this.typeSelect.off('change'); this.operatorSelect.off('change'); this.removeButton.off('click'); @@ -204,6 +205,10 @@ ConditionsUI.Condition = class Condition { this.parent.save(); } + isolate(object) { + return this.parent.isolate(object); + } + updateTypes() { const conditionDescriptors = this.parent.parent.conditionDescriptors; const optionGroup = this.typeSelect.find('optgroup'); @@ -236,21 +241,48 @@ ConditionsUI.Condition = class Condition { updateInput() { const conditionDescriptors = this.parent.parent.conditionDescriptors; const {type, operator} = this.condition; - const props = new Map([ - ['placeholder', ''], - ['type', 'text'] - ]); const objects = []; + let inputType = null; if (hasOwn(conditionDescriptors, type)) { const conditionDescriptor = conditionDescriptors[type]; objects.push(conditionDescriptor); + if (hasOwn(conditionDescriptor, 'type')) { + inputType = conditionDescriptor.type; + } if (hasOwn(conditionDescriptor.operators, operator)) { const operatorDescriptor = conditionDescriptor.operators[operator]; objects.push(operatorDescriptor); + if (hasOwn(operatorDescriptor, 'type')) { + inputType = operatorDescriptor.type; + } } } + this.input.empty(); + if (inputType === 'select') { + this.inputInner = this.createSelectElement(objects); + } else if (inputType === 'keyMulti') { + this.inputInner = this.createInputKeyMultiElement(objects); + } else { + this.inputInner = this.createInputElement(objects); + } + this.inputInner.appendTo(this.input); + this.inputInner.on('change', this.onInputChanged.bind(this)); + + const {valid, value} = this.validateValue(this.condition.value); + this.inputInner.toggleClass('is-invalid', !valid); + this.inputInner.val(value); + } + + createInputElement(objects) { + const inputInner = ConditionsUI.instantiateTemplate('#condition-input-text-template'); + + const props = new Map([ + ['placeholder', ''], + ['type', 'text'] + ]); + for (const object of objects) { if (hasOwn(object, 'placeholder')) { props.set('placeholder', object.placeholder); @@ -266,35 +298,124 @@ ConditionsUI.Condition = class Condition { } for (const [prop, value] of props.entries()) { - this.input.prop(prop, value); + inputInner.prop(prop, value); } - const {valid} = this.validateValue(this.condition.value); - this.input.toggleClass('is-invalid', !valid); - this.input.val(this.condition.value); + return inputInner; } - validateValue(value) { + createInputKeyMultiElement(objects) { + const inputInner = this.createInputElement(objects); + + inputInner.prop('readonly', true); + + let values = []; + let keySeparator = ' + '; + for (const object of objects) { + if (hasOwn(object, 'values')) { + values = object.values; + } + if (hasOwn(object, 'keySeparator')) { + keySeparator = object.keySeparator; + } + } + + const pressedKeyIndices = new Set(); + + const onKeyDown = ({originalEvent}) => { + const pressedKeyEventName = DOM.getKeyFromEvent(originalEvent); + if (pressedKeyEventName === 'Escape' || pressedKeyEventName === 'Backspace') { + pressedKeyIndices.clear(); + inputInner.val(''); + inputInner.change(); + return; + } + + const pressedModifiers = DOM.getActiveModifiers(originalEvent); + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey + // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta + // It works with mouse events on some platforms, so try to determine if metaKey is pressed + // hack; only works when Shift and Alt are not pressed + const isMetaKeyChrome = ( + pressedKeyEventName === 'Meta' && + getSetDifference(new Set(['shift', 'alt']), pressedModifiers).size !== 0 + ); + if (isMetaKeyChrome) { + pressedModifiers.add('meta'); + } + + for (const modifier of pressedModifiers) { + const foundIndex = values.findIndex(({optionValue}) => optionValue === modifier); + if (foundIndex !== -1) { + pressedKeyIndices.add(foundIndex); + } + } + + const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(keySeparator); + inputInner.val(inputValue); + inputInner.change(); + }; + + inputInner.on('keydown', onKeyDown); + + return inputInner; + } + + createSelectElement(objects) { + const inputInner = ConditionsUI.instantiateTemplate('#condition-input-select-template'); + + const data = new Map([ + ['values', []], + ['defaultValue', null] + ]); + + for (const object of objects) { + if (hasOwn(object, 'values')) { + data.set('values', object.values); + } + if (hasOwn(object, 'defaultValue')) { + data.set('defaultValue', this.isolate(object.defaultValue)); + } + } + + for (const {optionValue, name} of data.get('values')) { + const option = ConditionsUI.instantiateTemplate('#condition-input-option-template'); + option.attr('value', optionValue); + option.text(name); + option.appendTo(inputInner); + } + + const defaultValue = data.get('defaultValue'); + if (defaultValue !== null) { + inputInner.val(this.isolate(defaultValue)); + } + + return inputInner; + } + + validateValue(value, isInput=false) { const conditionDescriptors = this.parent.parent.conditionDescriptors; let valid = true; + let inputTransformedValue = null; try { - value = conditionsNormalizeOptionValue( + [value, inputTransformedValue] = conditionsNormalizeOptionValue( conditionDescriptors, this.condition.type, this.condition.operator, - value + value, + isInput ); } catch (e) { valid = false; } - return {valid, value}; + return {valid, value, inputTransformedValue}; } onInputChanged() { - const {valid, value} = this.validateValue(this.input.val()); - this.input.toggleClass('is-invalid', !valid); - this.input.val(value); - this.condition.value = value; + const {valid, value, inputTransformedValue} = this.validateValue(this.inputInner.val(), true); + this.inputInner.toggleClass('is-invalid', !valid); + this.inputInner.val(value); + this.condition.value = inputTransformedValue !== null ? inputTransformedValue : value; this.save(); } diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index 1a6d452b..632c01ea 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -17,8 +17,13 @@ /* global * PageExitPrevention + * apiDeleteDictionary + * apiGetDictionaryCounts + * apiGetDictionaryInfo + * apiImportDictionaryArchive * apiOptionsGet * apiOptionsGetFull + * apiPurgeDatabase * getOptionsContext * getOptionsFullMutable * getOptionsMutable @@ -26,11 +31,6 @@ * storageEstimate * storageUpdateStats * utilBackgroundIsolate - * utilDatabaseDeleteDictionary - * utilDatabaseGetDictionaryCounts - * utilDatabaseGetDictionaryInfo - * utilDatabaseImport - * utilDatabasePurge */ let dictionaryUI = null; @@ -312,7 +312,7 @@ class SettingsDictionaryEntryUI { progressBar.style.width = `${percent}%`; }; - await utilDatabaseDeleteDictionary(this.dictionaryInfo.title, onProgress, {rate: 1000}); + await apiDeleteDictionary(this.dictionaryInfo.title, onProgress); } catch (e) { dictionaryErrorsShow([e]); } finally { @@ -431,7 +431,7 @@ async function onDictionaryOptionsChanged() { async function onDatabaseUpdated() { try { - const dictionaries = await utilDatabaseGetDictionaryInfo(); + const dictionaries = await apiGetDictionaryInfo(); dictionaryUI.setDictionaries(dictionaries); document.querySelector('#dict-warning').hidden = (dictionaries.length > 0); @@ -439,7 +439,7 @@ async function onDatabaseUpdated() { updateMainDictionarySelectOptions(dictionaries); await updateMainDictionarySelectValue(); - const {counts, total} = await utilDatabaseGetDictionaryCounts(dictionaries.map((v) => v.title), true); + const {counts, total} = await apiGetDictionaryCounts(dictionaries.map((v) => v.title), true); dictionaryUI.setCounts(counts, total); } catch (e) { dictionaryErrorsShow([e]); @@ -554,7 +554,7 @@ function dictionaryErrorsShow(errors) { if (errors !== null && errors.length > 0) { const uniqueErrors = new Map(); for (let e of errors) { - logError(e); + yomichan.logError(e); e = dictionaryErrorToString(e); let count = uniqueErrors.get(e); if (typeof count === 'undefined') { @@ -618,7 +618,7 @@ async function onDictionaryPurge(e) { dictionaryErrorsShow(null); dictionarySpinnerShow(true); - await utilDatabasePurge(); + await apiPurgeDatabase(); for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) { options.dictionaries = utilBackgroundIsolate({}); options.general.mainDictionary = ''; @@ -679,7 +679,8 @@ async function onDictionaryImport(e) { dictImportInfo.textContent = `(${i + 1} of ${ii})`; } - const {result, errors} = await utilDatabaseImport(files[i], updateProgress, importDetails); + const archiveContent = await dictReadFile(files[i]); + const {result, errors} = await apiImportDictionaryArchive(archiveContent, importDetails, updateProgress); for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) { const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions(); dictionaryOptions.enabled = true; @@ -713,6 +714,15 @@ async function onDictionaryImport(e) { } } +function dictReadFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsBinaryString(file); + }); +} + async function onDatabaseEnablePrefixWildcardSearchesChanged(e) { const optionsFull = await getOptionsFullMutable(); diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index 308e92eb..61395b1c 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -21,6 +21,8 @@ * ankiInitialize * ankiTemplatesInitialize * ankiTemplatesUpdateValue + * apiForwardLogsToBackend + * apiGetEnvironmentInfo * apiOptionsSave * appearanceInitialize * audioSettingsInitialize @@ -130,6 +132,7 @@ async function formRead(options) { options.anki.tags = utilBackgroundIsolate($('#card-tags').val().split(/[,; ]+/)); options.anki.sentenceExt = parseInt($('#sentence-detection-extent').val(), 10); options.anki.server = $('#interface-server').val(); + options.anki.duplicateScope = $('#duplicate-scope').val(); options.anki.screenshot.format = $('#screenshot-format').val(); options.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10); @@ -211,6 +214,7 @@ async function formWrite(options) { $('#card-tags').val(options.anki.tags.join(' ')); $('#sentence-detection-extent').val(options.anki.sentenceExt); $('#interface-server').val(options.anki.server); + $('#duplicate-scope').val(options.anki.duplicateScope); $('#screenshot-format').val(options.anki.screenshot.format); $('#screenshot-quality').val(options.anki.screenshot.quality); @@ -282,12 +286,31 @@ function showExtensionInformation() { node.textContent = `${manifest.name} v${manifest.version}`; } +async function settingsPopulateModifierKeys() { + const scanModifierKeySelect = document.querySelector('#scan-modifier-key'); + scanModifierKeySelect.textContent = ''; + + const environment = await apiGetEnvironmentInfo(); + const modifierKeys = [ + {value: 'none', name: 'None'}, + ...environment.modifiers.keys + ]; + for (const {value, name} of modifierKeys) { + const option = document.createElement('option'); + option.value = value; + option.textContent = name; + scanModifierKeySelect.appendChild(option); + } +} + async function onReady() { + apiForwardLogsToBackend(); await yomichan.prepare(); showExtensionInformation(); + await settingsPopulateModifierKeys(); formSetupEventListeners(); appearanceInitialize(); await audioSettingsInitialize(); diff --git a/ext/bg/js/settings/popup-preview-frame-main.js b/ext/bg/js/settings/popup-preview-frame-main.js new file mode 100644 index 00000000..8228125f --- /dev/null +++ b/ext/bg/js/settings/popup-preview-frame-main.js @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2019-2020 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 + * SettingsPopupPreview + * apiForwardLogsToBackend + */ + +(() => { + apiForwardLogsToBackend(); + new SettingsPopupPreview(); +})(); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index fba114e2..8901a0c4 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -18,8 +18,9 @@ /* global * Frontend * Popup - * PopupProxyHost + * PopupFactory * TextSourceRange + * apiFrameInformationGet * apiOptionsGet */ @@ -32,46 +33,46 @@ class SettingsPopupPreview { this.popupShown = false; this.themeChangeTimeout = null; this.textSource = null; + this.optionsContext = null; this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); this._windowMessageHandlers = new Map([ + ['prepare', ({optionsContext}) => this.prepare(optionsContext)], ['setText', ({text}) => this.setText(text)], ['setCustomCss', ({css}) => this.setCustomCss(css)], - ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)] + ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)], + ['updateOptionsContext', ({optionsContext}) => this.updateOptionsContext(optionsContext)] ]); - } - static create() { - const instance = new SettingsPopupPreview(); - instance.prepare(); - return instance; + window.addEventListener('message', this.onMessage.bind(this), false); } - async prepare() { - // Setup events - window.addEventListener('message', this.onMessage.bind(this), false); + async prepare(optionsContext) { + this.optionsContext = optionsContext; + // Setup events document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false); // Overwrite API functions window.apiOptionsGet = this.apiOptionsGet.bind(this); // Overwrite frontend - const popupHost = new PopupProxyHost(); - await popupHost.prepare(); + const {frameId} = await apiFrameInformationGet(); + + const popupFactory = new PopupFactory(frameId); + await popupFactory.prepare(); - this.popup = popupHost.getOrCreatePopup(); + this.popup = popupFactory.getOrCreatePopup(); this.popup.setChildrenSupported(false); this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss; this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this); this.frontend = new Frontend(this.popup); - - this.frontend.setEnabled = () => {}; - this.frontend.searchClear = () => {}; - + this.frontend.getOptionsContext = async () => this.optionsContext; await this.frontend.prepare(); + this.frontend.setDisabledOverride(true); + this.frontend.canClearSelection = false; // Update search this.updateSearch(); @@ -122,7 +123,7 @@ class SettingsPopupPreview { } this.themeChangeTimeout = setTimeout(() => { this.themeChangeTimeout = null; - this.frontend.popup.updateTheme(); + this.popup.updateTheme(); }, 300); } @@ -143,12 +144,18 @@ class SettingsPopupPreview { setCustomCss(css) { if (this.frontend === null) { return; } - this.frontend.popup.setCustomCss(css); + this.popup.setCustomCss(css); } setCustomOuterCss(css) { if (this.frontend === null) { return; } - this.frontend.popup.setCustomOuterCss(css, false); + this.popup.setCustomOuterCss(css, false); + } + + async updateOptionsContext(optionsContext) { + this.optionsContext = optionsContext; + await this.frontend.updateOptions(); + await this.updateSearch(); } async updateSearch() { @@ -163,23 +170,17 @@ class SettingsPopupPreview { const source = new TextSourceRange(range, range.toString(), null, null); try { - await this.frontend.onSearchSource(source, 'script'); - this.frontend.setCurrentTextSource(source); + await this.frontend.setTextSource(source); } finally { source.cleanup(); } this.textSource = source; await this.frontend.showContentCompleted(); - if (this.frontend.popup.isVisibleSync()) { + if (this.popup.isVisibleSync()) { this.popupShown = true; } this.setInfoVisible(!this.popupShown); } } - -SettingsPopupPreview.instance = SettingsPopupPreview.create(); - - - diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js index 091872be..fdc3dd94 100644 --- a/ext/bg/js/settings/popup-preview.js +++ b/ext/bg/js/settings/popup-preview.js @@ -15,6 +15,10 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/* global + * getOptionsContext + * wanakana + */ function appearanceInitialize() { let previewVisible = false; @@ -37,7 +41,7 @@ function showAppearancePreview() { frame.src = '/bg/settings-popup-preview.html'; frame.id = 'settings-popup-preview-frame'; - window.wanakana.bind(text[0]); + wanakana.bind(text[0]); const targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); @@ -57,6 +61,23 @@ function showAppearancePreview() { frame.contentWindow.postMessage({action, params}, targetOrigin); }); + const updateOptionsContext = () => { + const action = 'updateOptionsContext'; + const params = { + optionsContext: getOptionsContext() + }; + frame.contentWindow.postMessage({action, params}, targetOrigin); + }; + yomichan.on('modifyingProfileChange', updateOptionsContext); + + frame.addEventListener('load', () => { + const action = 'prepare'; + const params = { + optionsContext: getOptionsContext() + }; + frame.contentWindow.postMessage({action, params}, targetOrigin); + }); + container.append(frame); buttonContainer.remove(); settings.css('display', ''); diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index 867b17aa..bdf5a13d 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -23,6 +23,7 @@ * getOptionsFullMutable * getOptionsMutable * profileConditionsDescriptor + * profileConditionsDescriptorPromise * settingsSaveOptions * utilBackgroundIsolate */ @@ -98,6 +99,7 @@ async function profileFormWrite(optionsFull) { profileConditionsContainer.cleanup(); } + await profileConditionsDescriptorPromise; profileConditionsContainer = new ConditionsUI.Container( profileConditionsDescriptor, 'popupLevel', @@ -128,7 +130,7 @@ function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndi } async function profileOptionsUpdateTarget(optionsFull) { - profileFormWrite(optionsFull); + await profileFormWrite(optionsFull); const optionsContext = getOptionsContext(); const options = await getOptionsMutable(optionsContext); @@ -190,6 +192,8 @@ async function onTargetProfileChanged() { currentProfileIndex = index; await profileOptionsUpdateTarget(optionsFull); + + yomichan.trigger('modifyingProfileChange'); } async function onProfileAdd() { @@ -197,9 +201,13 @@ async function onProfileAdd() { const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]); profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100); optionsFull.profiles.push(profile); + currentProfileIndex = optionsFull.profiles.length - 1; + await profileOptionsUpdateTarget(optionsFull); await settingsSaveOptions(); + + yomichan.trigger('modifyingProfileChange'); } async function onProfileRemove(e) { @@ -238,6 +246,8 @@ async function onProfileRemoveConfirm() { await profileOptionsUpdateTarget(optionsFull); await settingsSaveOptions(); + + yomichan.trigger('modifyingProfileChange'); } function onProfileNameChanged() { @@ -263,6 +273,8 @@ async function onProfileMove(offset) { await profileOptionsUpdateTarget(optionsFull); await settingsSaveOptions(); + + yomichan.trigger('modifyingProfileChange'); } async function onProfileCopy() { diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index aaa1a0ec..3fd329d1 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -45,14 +45,8 @@ class Translator { this.deinflector = new Deinflector(reasons); } - async purgeDatabase() { + clearDatabaseCaches() { this.tagCache.clear(); - await this.database.purge(); - } - - async deleteDictionary(dictionaryName) { - this.tagCache.clear(); - await this.database.deleteDictionary(dictionaryName); } async getSequencedDefinitions(definitions, mainDictionary) { @@ -482,7 +476,9 @@ class Translator { switch (mode) { case 'freq': for (const term of termsUnique[index]) { - term.frequencies.push({expression, frequency: data, dictionary}); + const frequencyData = this.getFrequencyData(expression, data, dictionary, term); + if (frequencyData === null) { continue; } + term.frequencies.push(frequencyData); } break; case 'pitch': @@ -575,6 +571,18 @@ class Translator { return tagMetaList; } + getFrequencyData(expression, data, dictionary, term) { + if (data !== null && typeof data === 'object') { + const {frequency, reading} = data; + + const termReading = term.reading || expression; + if (reading !== termReading) { return null; } + + return {expression, frequency, dictionary}; + } + return {expression, frequency: data, dictionary}; + } + async getPitchData(expression, data, dictionary, term) { const reading = data.reading; const termReading = term.reading || expression; diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 69536f02..8f86e47a 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -58,81 +58,14 @@ function utilBackgroundFunctionIsolate(func) { return backgroundPage.utilFunctionIsolate(func); } -function utilStringHashCode(string) { - let hashCode = 0; - - if (typeof string !== 'string') { return hashCode; } - - for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) { - hashCode = ((hashCode << 5) - hashCode) + charCode; - hashCode |= 0; - } - - return hashCode; -} - function utilBackend() { const backend = chrome.extension.getBackgroundPage().yomichanBackend; - if (!backend.isPrepared) { + if (!backend.isPrepared()) { throw new Error('Backend not ready yet'); } return backend; } -async function utilAnkiGetModelNames() { - return utilIsolate(await utilBackend().anki.getModelNames()); -} - -async function utilAnkiGetDeckNames() { - return utilIsolate(await utilBackend().anki.getDeckNames()); -} - -async function utilDatabaseGetDictionaryInfo() { - return utilIsolate(await utilBackend().translator.database.getDictionaryInfo()); -} - -async function utilDatabaseGetDictionaryCounts(dictionaryNames, getTotal) { - return utilIsolate(await utilBackend().translator.database.getDictionaryCounts( - utilBackgroundIsolate(dictionaryNames), - utilBackgroundIsolate(getTotal) - )); -} - -async function utilAnkiGetModelFieldNames(modelName) { - return utilIsolate(await utilBackend().anki.getModelFieldNames( - utilBackgroundIsolate(modelName) - )); -} - -async function utilDatabasePurge() { - return utilIsolate(await utilBackend().translator.purgeDatabase()); -} - -async function utilDatabaseDeleteDictionary(dictionaryName, onProgress) { - return utilIsolate(await utilBackend().translator.database.deleteDictionary( - utilBackgroundIsolate(dictionaryName), - utilBackgroundFunctionIsolate(onProgress) - )); -} - -async function utilDatabaseImport(data, onProgress, details) { - data = await utilReadFile(data); - return utilIsolate(await utilBackend().importDictionary( - utilBackgroundIsolate(data), - utilBackgroundFunctionIsolate(onProgress), - utilBackgroundIsolate(details) - )); -} - -function utilReadFile(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsBinaryString(file); - }); -} - function utilReadFileArrayBuffer(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/ext/bg/legal.html b/ext/bg/legal.html index 78acf79a..1ee9a28c 100644 --- a/ext/bg/legal.html +++ b/ext/bg/legal.html @@ -6,6 +6,7 @@ <title>Yomichan Legal</title> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> diff --git a/ext/bg/search.html b/ext/bg/search.html index eacc1893..f3f156d8 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -6,6 +6,7 @@ <title>Yomichan Search</title> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> @@ -13,8 +14,6 @@ <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css"> <link rel="stylesheet" type="text/css" href="/mixed/css/display.css"> - <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default"> - <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark"> </head> <body> <div class="container"> @@ -78,13 +77,14 @@ <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> - <script src="/bg/js/japanese.js"></script> <script src="/fg/js/document.js"></script> <script src="/fg/js/source.js"></script> <script src="/mixed/js/audio-system.js"></script> <script src="/mixed/js/display-context.js"></script> <script src="/mixed/js/display.js"></script> <script src="/mixed/js/display-generator.js"></script> + <script src="/mixed/js/dynamic-loader.js"></script> + <script src="/mixed/js/media-loader.js"></script> <script src="/mixed/js/scroll.js"></script> <script src="/mixed/js/text-scanner.js"></script> <script src="/mixed/js/template-handler.js"></script> @@ -93,6 +93,7 @@ <script src="/bg/js/search-query-parser.js"></script> <script src="/bg/js/clipboard-monitor.js"></script> <script src="/bg/js/search.js"></script> - <script src="/bg/js/search-frontend.js"></script> + + <script src="/bg/js/search-main.js"></script> </body> </html> diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html index f33ecedf..2f0b841b 100644 --- a/ext/bg/settings-popup-preview.html +++ b/ext/bg/settings-popup-preview.html @@ -6,6 +6,7 @@ <title>Yomichan Popup Preview</title> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> @@ -120,14 +121,17 @@ <script src="/mixed/js/core.js"></script> <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/api.js"></script> + <script src="/mixed/js/dynamic-loader.js"></script> <script src="/mixed/js/text-scanner.js"></script> <script src="/fg/js/document.js"></script> <script src="/fg/js/frontend-api-receiver.js"></script> <script src="/fg/js/popup.js"></script> <script src="/fg/js/source.js"></script> - <script src="/fg/js/popup-proxy-host.js"></script> + <script src="/fg/js/popup-factory.js"></script> <script src="/fg/js/frontend.js"></script> <script src="/bg/js/settings/popup-preview-frame.js"></script> + + <script src="/bg/js/settings/popup-preview-frame-main.js"></script> </body> </html> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 96c1db82..3ce91f12 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -6,6 +6,7 @@ <title>Yomichan Options</title> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> @@ -116,7 +117,7 @@ <div class="input-group-btn"><select class="form-control btn btn-default condition-type"><optgroup label="Type"></optgroup></select></div> <div class="input-group-btn"><select class="form-control btn btn-default condition-operator"><optgroup label="Operator"></optgroup></select></div> <div class="condition-line-break"></div> - <div class="condition-input"><input type="text" class="form-control" /></div> + <div class="condition-input"></div> <div class="input-group-btn"><button class="btn btn-danger condition-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div> </div></template> <template id="condition-group-separator-template"><div class="input-group"> @@ -125,6 +126,9 @@ <template id="condition-group-options-template"><div class="condition-group-options"> <button class="btn btn-default condition-add"><span class="glyphicon glyphicon-plus"></span></button> </div></template> + <template id="condition-input-text-template"><input type="text" class="form-control condition-input-inner" /></template> + <template id="condition-input-select-template"><select class="form-control condition-input-inner"></select></template> + <template id="condition-input-option-template"><option></option></template> </div> <div> @@ -408,12 +412,7 @@ <div class="form-group"> <label for="scan-modifier-key">Scan modifier key</label> - <select class="form-control" id="scan-modifier-key"> - <option value="none">None</option> - <option value="alt">Alt</option> - <option value="ctrl">Ctrl</option> - <option value="shift">Shift</option> - </select> + <select class="form-control" id="scan-modifier-key"></select> </div> </div> @@ -820,6 +819,14 @@ </div> <div class="form-group options-advanced"> + <label for="duplicate-scope">Duplicate scope</label> + <select class="form-control" id="duplicate-scope"> + <option value="collection">Collection</option> + <option value="deck">Deck</option> + </select> + </div> + + <div class="form-group options-advanced"> <label for="screenshot-format">Screenshot format</label> <select class="form-control" id="screenshot-format"> <option value="png">PNG</option> @@ -838,6 +845,8 @@ As Anki requires the first field in the model to be unique, it is recommended that you set it to <code>{expression}</code> for term flashcards and <code>{character}</code> for Kanji flashcards. You can use multiple markers per field by typing them in directly. + See <a href="https://foosoft.net/projects/yomichan#flashcard-configuration" target="_blank" rel="noopener">Flashcard Configuration</a> + on the Yomichan homepage for descriptions of the available markers. </p> <ul class="nav nav-tabs"> @@ -1116,6 +1125,7 @@ <script src="/mixed/js/core.js"></script> <script src="/mixed/js/dom.js"></script> + <script src="/mixed/js/environment.js"></script> <script src="/mixed/js/api.js"></script> <script src="/mixed/js/japanese.js"></script> @@ -1124,7 +1134,6 @@ <script src="/bg/js/conditions.js"></script> <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> - <script src="/bg/js/japanese.js"></script> <script src="/bg/js/options.js"></script> <script src="/bg/js/page-exit-prevention.js"></script> <script src="/bg/js/profile-conditions.js"></script> diff --git a/ext/fg/float.html b/ext/fg/float.html index 3ccf68eb..89952524 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -6,13 +6,12 @@ <title></title> <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> <link rel="stylesheet" href="/mixed/css/display.css"> - <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default"> - <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark"> </head> <body> <div id="spinner" hidden><img src="/mixed/img/spinner.gif"></div> @@ -51,11 +50,13 @@ <script src="/mixed/js/display-context.js"></script> <script src="/mixed/js/display.js"></script> <script src="/mixed/js/display-generator.js"></script> + <script src="/mixed/js/dynamic-loader.js"></script> + <script src="/mixed/js/media-loader.js"></script> <script src="/mixed/js/scroll.js"></script> <script src="/mixed/js/template-handler.js"></script> <script src="/fg/js/float.js"></script> - <script src="/fg/js/popup-nested.js"></script> + <script src="/fg/js/float-main.js"></script> </body> </html> diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/content-script-main.js index 2b942258..57386b85 100644 --- a/ext/fg/js/frontend-initialize.js +++ b/ext/fg/js/content-script-main.js @@ -16,15 +16,18 @@ */ /* global + * DOM * FrameOffsetForwarder * Frontend + * PopupFactory * PopupProxy - * PopupProxyHost * apiBroadcastTab + * apiForwardLogsToBackend + * apiFrameInformationGet * apiOptionsGet */ -async function createIframePopupProxy(url, frameOffsetForwarder) { +async function createIframePopupProxy(frameOffsetForwarder, setDisabled) { const rootPopupInformationPromise = yomichan.getTemporaryListenerResult( chrome.runtime.onMessage, ({action, params}, {resolve}) => { @@ -34,33 +37,41 @@ async function createIframePopupProxy(url, frameOffsetForwarder) { } ); apiBroadcastTab('rootPopupRequestInformationBroadcast'); - const {popupId, frameId} = await rootPopupInformationPromise; + const {popupId, frameId: parentFrameId} = await rootPopupInformationPromise; const getFrameOffset = frameOffsetForwarder.getOffset.bind(frameOffsetForwarder); - const popup = new PopupProxy(popupId, 0, null, frameId, url, getFrameOffset); + const popup = new PopupProxy(popupId, 0, null, parentFrameId, getFrameOffset, setDisabled); await popup.prepare(); return popup; } async function getOrCreatePopup(depth) { - const popupHost = new PopupProxyHost(); - await popupHost.prepare(); + const {frameId} = await apiFrameInformationGet(); + if (typeof frameId !== 'number') { + const error = new Error('Failed to get frameId'); + yomichan.logError(error); + throw error; + } - const popup = popupHost.getOrCreatePopup(null, null, depth); + const popupFactory = new PopupFactory(frameId); + await popupFactory.prepare(); + + const popup = popupFactory.getOrCreatePopup(null, null, depth); return popup; } -async function createPopupProxy(depth, id, parentFrameId, url) { - const popup = new PopupProxy(null, depth + 1, id, parentFrameId, url); +async function createPopupProxy(depth, id, parentFrameId) { + const popup = new PopupProxy(null, depth + 1, id, parentFrameId); await popup.prepare(); return popup; } -async function main() { +(async () => { + apiForwardLogsToBackend(); await yomichan.prepare(); const data = window.frontendInitializationData || {}; @@ -78,8 +89,29 @@ async function main() { let frontendPreparePromise = null; let frameOffsetForwarder = null; + let iframePopupsInRootFrameAvailable = true; + + const disableIframePopupsInRootFrame = () => { + iframePopupsInRootFrameAvailable = false; + applyOptions(); + }; + + let urlUpdatedAt = 0; + let popupProxyUrlCached = url; + const getPopupProxyUrl = async () => { + const now = Date.now(); + if (popups.proxy !== null && now - urlUpdatedAt > 500) { + popupProxyUrlCached = await popups.proxy.getUrl(); + urlUpdatedAt = now; + } + return popupProxyUrlCached; + }; + const applyOptions = async () => { - const optionsContext = {depth: isSearchPage ? 0 : depth, url}; + const optionsContext = { + depth: isSearchPage ? 0 : depth, + url: proxy ? await getPopupProxyUrl() : window.location.href + }; const options = await apiOptionsGet(optionsContext); if (!proxy && frameOffsetForwarder === null) { @@ -88,11 +120,11 @@ async function main() { } let popup; - if (isIframe && options.general.showIframePopupsInRootFrame) { - popup = popups.iframe || await createIframePopupProxy(url, frameOffsetForwarder); + if (isIframe && options.general.showIframePopupsInRootFrame && DOM.getFullscreenElement() === null && iframePopupsInRootFrameAvailable) { + popup = popups.iframe || await createIframePopupProxy(frameOffsetForwarder, disableIframePopupsInRootFrame); popups.iframe = popup; } else if (proxy) { - popup = popups.proxy || await createPopupProxy(depth, id, parentFrameId, url); + popup = popups.proxy || await createPopupProxy(depth, id, parentFrameId); popups.proxy = popup; } else { popup = popups.normal || await getOrCreatePopup(depth); @@ -100,7 +132,8 @@ async function main() { } if (frontend === null) { - frontend = new Frontend(popup); + const getUrl = proxy ? getPopupProxyUrl : null; + frontend = new Frontend(popup, getUrl); frontendPreparePromise = frontend.prepare(); await frontendPreparePromise; } else { @@ -117,8 +150,7 @@ async function main() { }; yomichan.on('optionsUpdated', applyOptions); + window.addEventListener('fullscreenchange', applyOptions, false); await applyOptions(); -} - -main(); +})(); diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index 3b4cc28f..d639bc86 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -28,6 +28,9 @@ function docSetImposterStyle(style, propertyName, value) { } function docImposterCreate(element, isTextarea) { + const body = document.body; + if (body === null) { return [null, null]; } + const elementStyle = window.getComputedStyle(element); const elementRect = element.getBoundingClientRect(); const documentRect = document.documentElement.getBoundingClientRect(); @@ -78,7 +81,7 @@ function docImposterCreate(element, isTextarea) { } container.appendChild(imposter); - document.body.appendChild(container); + body.appendChild(container); // Adjust size const imposterRect = imposter.getBoundingClientRect(); @@ -156,7 +159,7 @@ function docSentenceExtract(source, extent) { const sourceLocal = source.clone(); const position = sourceLocal.setStartOffset(extent); - sourceLocal.setEndOffset(position + extent); + sourceLocal.setEndOffset(extent * 2 - position, true); const content = sourceLocal.text(); let quoteStack = []; diff --git a/ext/fg/js/dom-text-scanner.js b/ext/fg/js/dom-text-scanner.js new file mode 100644 index 00000000..8fa67ede --- /dev/null +++ b/ext/fg/js/dom-text-scanner.js @@ -0,0 +1,551 @@ +/* + * Copyright (C) 2020 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/>. + */ + +/** + * A class used to scan text in a document. + */ +class DOMTextScanner { + /** + * Creates a new instance of a DOMTextScanner. + * @param node The DOM Node to start at. + * @param offset The character offset in to start at when node is a text node. + * Use 0 for non-text nodes. + */ + constructor(node, offset, forcePreserveWhitespace=false, generateLayoutContent=true) { + const ruby = DOMTextScanner.getParentRubyElement(node); + const resetOffset = (ruby !== null); + if (resetOffset) { node = ruby; } + + this._node = node; + this._offset = offset; + this._content = ''; + this._remainder = 0; + this._resetOffset = resetOffset; + this._newlines = 0; + this._lineHasWhitespace = false; + this._lineHasContent = false; + this._forcePreserveWhitespace = forcePreserveWhitespace; + this._generateLayoutContent = generateLayoutContent; + } + + /** + * Gets the current node being scanned. + * @returns A DOM Node. + */ + get node() { + return this._node; + } + + /** + * Gets the current offset corresponding to the node being scanned. + * This value is only applicable for text nodes. + * @returns An integer. + */ + get offset() { + return this._offset; + } + + /** + * Gets the remaining number of characters that weren't scanned in the last seek() call. + * This value is usually 0 unless the end of the document was reached. + * @returns An integer. + */ + get remainder() { + return this._remainder; + } + + /** + * Gets the accumulated content string resulting from calls to seek(). + * @returns A string. + */ + get content() { + return this._content; + } + + /** + * Seeks a given length in the document and accumulates the text content. + * @param length A positive or negative integer corresponding to how many characters + * should be added to content. Content is only added to the accumulation string, + * never removed, so mixing seek calls with differently signed length values + * may give unexpected results. + * @returns this + */ + seek(length) { + const forward = (length >= 0); + this._remainder = (forward ? length : -length); + if (length === 0) { return this; } + + const TEXT_NODE = Node.TEXT_NODE; + const ELEMENT_NODE = Node.ELEMENT_NODE; + + const generateLayoutContent = this._generateLayoutContent; + let node = this._node; + let lastNode = node; + let resetOffset = this._resetOffset; + let newlines = 0; + while (node !== null) { + let enterable = false; + const nodeType = node.nodeType; + + if (nodeType === TEXT_NODE) { + lastNode = node; + if (!( + forward ? + this._seekTextNodeForward(node, resetOffset) : + this._seekTextNodeBackward(node, resetOffset) + )) { + // Length reached + break; + } + } else if (nodeType === ELEMENT_NODE) { + lastNode = node; + this._offset = 0; + [enterable, newlines] = DOMTextScanner.getElementSeekInfo(node); + if (newlines > this._newlines && generateLayoutContent) { + this._newlines = newlines; + } + } + + const exitedNodes = []; + node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes); + + for (const exitedNode of exitedNodes) { + if (exitedNode.nodeType !== ELEMENT_NODE) { continue; } + newlines = DOMTextScanner.getElementSeekInfo(exitedNode)[1]; + if (newlines > this._newlines && generateLayoutContent) { + this._newlines = newlines; + } + } + + resetOffset = true; + } + + this._node = lastNode; + this._resetOffset = resetOffset; + + return this; + } + + // Private + + /** + * Seeks forward in a text node. + * @param textNode The text node to use. + * @param resetOffset Whether or not the text offset should be reset. + * @returns true if scanning should continue, or false if the scan length has been reached. + */ + _seekTextNodeForward(textNode, resetOffset) { + const nodeValue = textNode.nodeValue; + const nodeValueLength = nodeValue.length; + const [preserveNewlines, preserveWhitespace] = ( + this._forcePreserveWhitespace ? + [true, true] : + DOMTextScanner.getWhitespaceSettings(textNode) + ); + + let lineHasWhitespace = this._lineHasWhitespace; + let lineHasContent = this._lineHasContent; + let content = this._content; + let offset = resetOffset ? 0 : this._offset; + let remainder = this._remainder; + let newlines = this._newlines; + + while (offset < nodeValueLength) { + const char = nodeValue[offset]; + const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace); + ++offset; + + if (charAttributes === 0) { + // Character should be ignored + continue; + } else if (charAttributes === 1) { + // Character is collapsable whitespace + lineHasWhitespace = true; + } else { + // Character should be added to the content + if (newlines > 0) { + if (content.length > 0) { + const useNewlineCount = Math.min(remainder, newlines); + content += '\n'.repeat(useNewlineCount); + remainder -= useNewlineCount; + newlines -= useNewlineCount; + } else { + newlines = 0; + } + lineHasContent = false; + lineHasWhitespace = false; + if (remainder <= 0) { + --offset; // Revert character offset + break; + } + } + + lineHasContent = (charAttributes === 2); // 3 = character is a newline + + if (lineHasWhitespace) { + if (lineHasContent) { + content += ' '; + lineHasWhitespace = false; + if (--remainder <= 0) { + --offset; // Revert character offset + break; + } + } else { + lineHasWhitespace = false; + } + } + + content += char; + + if (--remainder <= 0) { break; } + } + } + + this._lineHasWhitespace = lineHasWhitespace; + this._lineHasContent = lineHasContent; + this._content = content; + this._offset = offset; + this._remainder = remainder; + this._newlines = newlines; + + return (remainder > 0); + } + + /** + * Seeks backward in a text node. + * This function is nearly the same as _seekTextNodeForward, with the following differences: + * - Iteration condition is reversed to check if offset is greater than 0. + * - offset is reset to nodeValueLength instead of 0. + * - offset is decremented instead of incremented. + * - offset is decremented before getting the character. + * - offset is reverted by incrementing instead of decrementing. + * - content string is prepended instead of appended. + * @param textNode The text node to use. + * @param resetOffset Whether or not the text offset should be reset. + * @returns true if scanning should continue, or false if the scan length has been reached. + */ + _seekTextNodeBackward(textNode, resetOffset) { + const nodeValue = textNode.nodeValue; + const nodeValueLength = nodeValue.length; + const [preserveNewlines, preserveWhitespace] = ( + this._forcePreserveWhitespace ? + [true, true] : + DOMTextScanner.getWhitespaceSettings(textNode) + ); + + let lineHasWhitespace = this._lineHasWhitespace; + let lineHasContent = this._lineHasContent; + let content = this._content; + let offset = resetOffset ? nodeValueLength : this._offset; + let remainder = this._remainder; + let newlines = this._newlines; + + while (offset > 0) { + --offset; + const char = nodeValue[offset]; + const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace); + + if (charAttributes === 0) { + // Character should be ignored + continue; + } else if (charAttributes === 1) { + // Character is collapsable whitespace + lineHasWhitespace = true; + } else { + // Character should be added to the content + if (newlines > 0) { + if (content.length > 0) { + const useNewlineCount = Math.min(remainder, newlines); + content = '\n'.repeat(useNewlineCount) + content; + remainder -= useNewlineCount; + newlines -= useNewlineCount; + } else { + newlines = 0; + } + lineHasContent = false; + lineHasWhitespace = false; + if (remainder <= 0) { + ++offset; // Revert character offset + break; + } + } + + lineHasContent = (charAttributes === 2); // 3 = character is a newline + + if (lineHasWhitespace) { + if (lineHasContent) { + content = ' ' + content; + lineHasWhitespace = false; + if (--remainder <= 0) { + ++offset; // Revert character offset + break; + } + } else { + lineHasWhitespace = false; + } + } + + content = char + content; + + if (--remainder <= 0) { break; } + } + } + + this._lineHasWhitespace = lineHasWhitespace; + this._lineHasContent = lineHasContent; + this._content = content; + this._offset = offset; + this._remainder = remainder; + this._newlines = newlines; + + return (remainder > 0); + } + + // Static helpers + + /** + * Gets the next node in the document for a specified scanning direction. + * @param node The current DOM Node. + * @param forward Whether to scan forward in the document or backward. + * @param visitChildren Whether the children of the current node should be visited. + * @param exitedNodes An array which stores nodes which were exited. + * @returns The next node in the document, or null if there is no next node. + */ + static getNextNode(node, forward, visitChildren, exitedNodes) { + let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null; + if (next === null) { + while (true) { + exitedNodes.push(node); + + next = (forward ? node.nextSibling : node.previousSibling); + if (next !== null) { break; } + + next = node.parentNode; + if (next === null) { break; } + + node = next; + } + } + return next; + } + + /** + * Gets the parent element of a given Node. + * @param node The node to check. + * @returns The parent element if one exists, otherwise null. + */ + static getParentElement(node) { + while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { + node = node.parentNode; + } + return node; + } + + /** + * Gets the parent <ruby> element of a given node, if one exists. For efficiency purposes, + * this only checks the immediate parent elements and does not check all ancestors, so + * there are cases where the node may be in a ruby element but it is not returned. + * @param node The node to check. + * @returns A <ruby> node if the input node is contained in one, otherwise null. + */ + static getParentRubyElement(node) { + node = DOMTextScanner.getParentElement(node); + if (node !== null && node.nodeName.toUpperCase() === 'RT') { + node = node.parentNode; + if (node !== null && node.nodeName.toUpperCase() === 'RUBY') { + return node; + } + } + return null; + } + + /** + * @returns [enterable: boolean, newlines: integer] + * The enterable value indicates whether the content of this node should be entered. + * The newlines value corresponds to the number of newline characters that should be added. + * 1 newline corresponds to a simple new line in the layout. + * 2 newlines corresponds to a significant visual distinction since the previous content. + */ + static getElementSeekInfo(element) { + let enterable = true; + switch (element.nodeName.toUpperCase()) { + case 'HEAD': + case 'RT': + case 'SCRIPT': + case 'STYLE': + return [false, 0]; + case 'BR': + return [false, 1]; + case 'TEXTAREA': + case 'INPUT': + case 'BUTTON': + enterable = false; + break; + } + + const style = window.getComputedStyle(element); + const display = style.display; + + const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style)); + let newlines = 0; + + if (!visible) { + enterable = false; + } else { + switch (style.position) { + case 'absolute': + case 'fixed': + case 'sticky': + newlines = 2; + break; + } + if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) { + newlines = 1; + } + } + + return [enterable, newlines]; + } + + /** + * Gets information about how whitespace characters are treated. + * @param textNode The Text node to check. + * @returns [preserveNewlines: boolean, preserveWhitespace: boolean] + * The value of preserveNewlines indicates whether or not newline characters are treated as line breaks. + * The value of preserveWhitespace indicates whether or not sequences of whitespace characters are collapsed. + */ + static getWhitespaceSettings(textNode) { + const element = DOMTextScanner.getParentElement(textNode); + if (element !== null) { + const style = window.getComputedStyle(element); + switch (style.whiteSpace) { + case 'pre': + case 'pre-wrap': + case 'break-spaces': + return [true, true]; + case 'pre-line': + return [true, false]; + } + } + return [false, false]; + } + + /** + * Gets attributes for the specified character. + * @param character A string containing a single character. + * @returns An integer representing the attributes of the character. + * 0: Character should be ignored. + * 1: Character is collapsable whitespace. + * 2: Character should be added to the content. + * 3: Character should be added to the content and is a newline. + */ + static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) { + switch (character.charCodeAt(0)) { + case 0x09: // Tab ('\t') + case 0x0c: // Form feed ('\f') + case 0x0d: // Carriage return ('\r') + case 0x20: // Space (' ') + return preserveWhitespace ? 2 : 1; + case 0x0a: // Line feed ('\n') + return preserveNewlines ? 3 : 1; + case 0x200c: // Zero-width non-joiner ('\u200c') + return 0; + default: // Other + return 2; + } + } + + /** + * Checks whether a given style is visible or not. + * This function does not check style.display === 'none'. + * @param style An object implementing the CSSStyleDeclaration interface. + * @returns true if the style should result in an element being visible, otherwise false. + */ + static isStyleVisible(style) { + return !( + style.visibility === 'hidden' || + parseFloat(style.opacity) <= 0 || + parseFloat(style.fontSize) <= 0 || + ( + !DOMTextScanner.isStyleSelectable(style) && + ( + DOMTextScanner.isCSSColorTransparent(style.color) || + DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor) + ) + ) + ); + } + + /** + * Checks whether a given style is selectable or not. + * @param style An object implementing the CSSStyleDeclaration interface. + * @returns true if the style is selectable, otherwise false. + */ + static isStyleSelectable(style) { + return !( + style.userSelect === 'none' || + style.webkitUserSelect === 'none' || + style.MozUserSelect === 'none' || + style.msUserSelect === 'none' + ); + } + + /** + * Checks whether a CSS color is transparent or not. + * @param cssColor A CSS color string, expected to be encoded in rgb(a) form. + * @returns true if the color is transparent, otherwise false. + */ + static isCSSColorTransparent(cssColor) { + return ( + typeof cssColor === 'string' && + cssColor.startsWith('rgba(') && + /,\s*0.?0*\)$/.test(cssColor) + ); + } + + /** + * Checks whether a CSS display value will cause a layout change for text. + * @param cssDisplay A CSS string corresponding to the value of the display property. + * @returns true if the layout is changed by this value, otherwise false. + */ + static doesCSSDisplayChangeLayout(cssDisplay) { + let pos = cssDisplay.indexOf(' '); + if (pos >= 0) { + // Truncate to <display-outside> part + cssDisplay = cssDisplay.substring(0, pos); + } + + pos = cssDisplay.indexOf('-'); + if (pos >= 0) { + // Truncate to first part of kebab-case value + cssDisplay = cssDisplay.substring(0, pos); + } + + switch (cssDisplay) { + case 'block': + case 'flex': + case 'grid': + case 'list': // list-item + case 'table': // table, table-* + return true; + case 'ruby': // rubt-* + return (pos >= 0); + default: + return false; + } + } +} diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/float-main.js index c140f9c8..20771910 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/float-main.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019-2020 Yomichan Authors + * Copyright (C) 2020 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 @@ -16,24 +16,21 @@ */ /* global + * DisplayFloat + * apiForwardLogsToBackend * apiOptionsGet + * dynamicLoader */ -function injectPopupNested() { - const scriptSrcs = [ +async function injectPopupNested() { + await dynamicLoader.loadScripts([ '/mixed/js/text-scanner.js', '/fg/js/frontend-api-sender.js', '/fg/js/popup.js', '/fg/js/popup-proxy.js', '/fg/js/frontend.js', - '/fg/js/frontend-initialize.js' - ]; - for (const src of scriptSrcs) { - const script = document.createElement('script'); - script.async = false; - script.src = src; - document.body.appendChild(script); - } + '/fg/js/content-script-main.js' + ]); } async function popupNestedInitialize(id, depth, parentFrameId, url) { @@ -42,26 +39,23 @@ async function popupNestedInitialize(id, depth, parentFrameId, url) { const applyOptions = async () => { const optionsContext = {depth, url}; const options = await apiOptionsGet(optionsContext); - const popupNestingMaxDepth = options.scanning.popupNestingMaxDepth; - - const maxPopupDepthExceeded = !( - typeof popupNestingMaxDepth === 'number' && - typeof depth === 'number' && - depth < popupNestingMaxDepth - ); - if (maxPopupDepthExceeded || optionsApplied) { - return; - } + const maxPopupDepthExceeded = !(typeof depth === 'number' && depth < options.scanning.popupNestingMaxDepth); + if (maxPopupDepthExceeded || optionsApplied) { return; } optionsApplied = true; + yomichan.off('optionsUpdated', applyOptions); window.frontendInitializationData = {id, depth, parentFrameId, url, proxy: true}; - injectPopupNested(); - - yomichan.off('optionsUpdated', applyOptions); + await injectPopupNested(); }; yomichan.on('optionsUpdated', applyOptions); await applyOptions(); } + +(async () => { + apiForwardLogsToBackend(); + const display = new DisplayFloat(); + await display.prepare(); +})(); diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 5c2c50c2..845bf7f6 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -18,7 +18,7 @@ /* global * Display * apiBroadcastTab - * apiGetMessageToken + * apiSendMessageToFrame * popupNestedInitialize */ @@ -27,17 +27,11 @@ class DisplayFloat extends Display { super(document.querySelector('#spinner'), document.querySelector('#definitions')); this.autoPlayAudioTimer = null; - this._popupId = null; - - this.optionsContext = { - depth: 0, - url: window.location.href - }; + this._secret = yomichan.generateId(16); + this._token = null; this._orphaned = false; - this._prepareInvoked = false; - this._messageToken = null; - this._messageTokenPromise = null; + this._initializedNestedPopups = false; this._onKeyDownHandlers = new Map([ ['C', (e) => { @@ -51,42 +45,30 @@ class DisplayFloat extends Display { ]); this._windowMessageHandlers = new Map([ - ['setContent', ({type, details}) => this.setContent(type, details)], - ['clearAutoPlayTimer', () => this.clearAutoPlayTimer()], - ['setCustomCss', ({css}) => this.setCustomCss(css)], - ['prepare', ({popupInfo, url, childrenSupported, scale}) => this.prepare(popupInfo, url, childrenSupported, scale)], - ['setContentScale', ({scale}) => this.setContentScale(scale)] + ['initialize', {handler: this._initialize.bind(this), authenticate: false}], + ['configure', {handler: this._configure.bind(this)}], + ['setOptionsContext', {handler: ({optionsContext}) => this.setOptionsContext(optionsContext)}], + ['setContent', {handler: ({type, details}) => this.setContent(type, details)}], + ['clearAutoPlayTimer', {handler: () => this.clearAutoPlayTimer()}], + ['setCustomCss', {handler: ({css}) => this.setCustomCss(css)}], + ['setContentScale', {handler: ({scale}) => this.setContentScale(scale)}] ]); - - yomichan.on('orphaned', this.onOrphaned.bind(this)); - window.addEventListener('message', this.onMessage.bind(this), false); } - async prepare(popupInfo, url, childrenSupported, scale) { - if (this._prepareInvoked) { return; } - this._prepareInvoked = true; - - const {id, depth, parentFrameId} = popupInfo; - this._popupId = id; - this.optionsContext.depth = depth; - this.optionsContext.url = url; - + async prepare() { await super.prepare(); - if (childrenSupported) { - popupNestedInitialize(id, depth, parentFrameId, url); - } - - this.setContentScale(scale); + yomichan.on('orphaned', this.onOrphaned.bind(this)); + window.addEventListener('message', this.onMessage.bind(this), false); - apiBroadcastTab('popupPrepareCompleted', {targetPopupId: this._popupId}); + apiBroadcastTab('popupPrepared', {secret: this._secret}); } onError(error) { if (this._orphaned) { this.setContent('orphaned'); } else { - logError(error, true); + yomichan.logError(error); } } @@ -94,7 +76,7 @@ class DisplayFloat extends Display { this._orphaned = true; } - onSearchClear() { + onEscape() { window.parent.postMessage('popupClose', '*'); } @@ -104,46 +86,30 @@ class DisplayFloat extends Display { onMessage(e) { const data = e.data; - if (typeof data !== 'object' || data === null) { return; } // Invalid data - - const token = data.token; - if (typeof token !== 'string') { return; } // Invalid data - - if (this._messageToken === null) { - // Async - this.getMessageToken() - .then( - () => { this.handleAction(token, data); }, - () => {} - ); - } else { - // Sync - this.handleAction(token, data); + if (typeof data !== 'object' || data === null) { + this._logMessageError(e, 'Invalid data'); + return; } - } - async getMessageToken() { - // this._messageTokenPromise is used to ensure that only one call to apiGetMessageToken is made. - if (this._messageTokenPromise === null) { - this._messageTokenPromise = apiGetMessageToken(); - } - const messageToken = await this._messageTokenPromise; - if (this._messageToken === null) { - this._messageToken = messageToken; + const action = data.action; + if (typeof action !== 'string') { + this._logMessageError(e, 'Invalid data'); + return; } - this._messageTokenPromise = null; - } - handleAction(token, {action, params}) { - if (token !== this._messageToken) { - // Invalid token + const handlerInfo = this._windowMessageHandlers.get(action); + if (typeof handlerInfo === 'undefined') { + this._logMessageError(e, `Invalid action: ${JSON.stringify(action)}`); return; } - const handler = this._windowMessageHandlers.get(action); - if (typeof handler !== 'function') { return; } + if (handlerInfo.authenticate !== false && !this._isMessageAuthenticated(data)) { + this._logMessageError(e, 'Invalid authentication'); + return; + } - handler(params); + const handler = handlerInfo.handler; + handler(data.params); } autoPlayAudio() { @@ -158,8 +124,15 @@ class DisplayFloat extends Display { } } + async setOptionsContext(optionsContext) { + this.optionsContext = optionsContext; + await this.updateOptions(); + } + setContentScale(scale) { - document.body.style.fontSize = `${scale}em`; + const body = document.body; + if (body === null) { return; } + body.style.fontSize = `${scale}em`; } async getDocumentTitle() { @@ -188,6 +161,45 @@ class DisplayFloat extends Display { return ''; } } -} -DisplayFloat.instance = new DisplayFloat(); + _logMessageError(event, type) { + yomichan.logWarning(new Error(`Popup received invalid message from origin ${JSON.stringify(event.origin)}: ${type}`)); + } + + _initialize(params) { + if (this._token !== null) { return; } // Already initialized + if (!isObject(params)) { return; } // Invalid data + + const secret = params.secret; + if (secret !== this._secret) { return; } // Invalid authentication + + const {token, frameId} = params; + this._token = token; + + apiSendMessageToFrame(frameId, 'popupInitialized', {secret, token}); + } + + async _configure({messageId, frameId, popupId, optionsContext, childrenSupported, scale}) { + this.optionsContext = optionsContext; + + await this.updateOptions(); + + if (childrenSupported && !this._initializedNestedPopups) { + const {depth, url} = optionsContext; + popupNestedInitialize(popupId, depth, frameId, url); + this._initializedNestedPopups = true; + } + + this.setContentScale(scale); + + apiSendMessageToFrame(frameId, 'popupConfigured', {messageId}); + } + + _isMessageAuthenticated(message) { + return ( + this._token !== null && + this._token === message.token && + this._secret === message.secret + ); + } +} diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js index c658c55a..9b68d34e 100644 --- a/ext/fg/js/frame-offset-forwarder.js +++ b/ext/fg/js/frame-offset-forwarder.js @@ -23,6 +23,10 @@ class FrameOffsetForwarder { constructor() { this._started = false; + this._cacheMaxSize = 1000; + this._frameCache = new Set(); + this._unreachableContentWindowCache = new Set(); + this._forwardFrameOffset = ( window !== window.parent ? this._forwardFrameOffsetParent.bind(this) : @@ -74,12 +78,12 @@ class FrameOffsetForwarder { _onGetFrameOffset(offset, uniqueId, e) { let sourceFrame = null; - for (const frame of document.querySelectorAll('frame, iframe:not(.yomichan-float)')) { - if (frame.contentWindow !== e.source) { continue; } - sourceFrame = frame; - break; + if (!this._unreachableContentWindowCache.has(e.source)) { + sourceFrame = this._findFrameWithContentWindow(e.source); } if (sourceFrame === null) { + // closed shadow root etc. + this._addToCache(this._unreachableContentWindowCache, e.source); this._forwardFrameOffsetOrigin(null, uniqueId); return; } @@ -91,6 +95,67 @@ class FrameOffsetForwarder { this._forwardFrameOffset(offset, uniqueId); } + _findFrameWithContentWindow(contentWindow) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (const elements of this._getFrameElementSources()) { + while (elements.length > 0) { + const element = elements.shift(); + if (element.contentWindow === contentWindow) { + this._addToCache(this._frameCache, element); + return element; + } + + const shadowRoot = ( + element.shadowRoot || + element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + ); + if (shadowRoot) { + for (const child of shadowRoot.children) { + if (child.nodeType === ELEMENT_NODE) { + elements.push(child); + } + } + } + + for (const child of element.children) { + if (child.nodeType === ELEMENT_NODE) { + elements.push(child); + } + } + } + } + + return null; + } + + *_getFrameElementSources() { + const frameCache = []; + for (const frame of this._frameCache) { + // removed from DOM + if (!frame.isConnected) { + this._frameCache.delete(frame); + continue; + } + frameCache.push(frame); + } + yield frameCache; + // will contain duplicates, but frame elements are cheap to handle + yield [...document.querySelectorAll('frame, iframe:not(.yomichan-float)')]; + yield [document.documentElement]; + } + + _addToCache(cache, value) { + let freeSlots = this._cacheMaxSize - cache.size; + if (freeSlots <= 0) { + for (const cachedValue of cache) { + cache.delete(cachedValue); + ++freeSlots; + if (freeSlots > 0) { break; } + } + } + cache.add(value); + } + _forwardFrameOffsetParent(offset, uniqueId) { window.parent.postMessage({action: 'getFrameOffset', params: {offset, uniqueId}}, '*'); } diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js index 4abd4e81..3fa9e8b6 100644 --- a/ext/fg/js/frontend-api-receiver.js +++ b/ext/fg/js/frontend-api-receiver.js @@ -17,41 +17,60 @@ class FrontendApiReceiver { - constructor(source='', handlers=new Map()) { + constructor(source, messageHandlers) { this._source = source; - this._handlers = handlers; + this._messageHandlers = messageHandlers; + } - chrome.runtime.onConnect.addListener(this.onConnect.bind(this)); + prepare() { + chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); } - onConnect(port) { + _onConnect(port) { if (port.name !== 'frontend-api-receiver') { return; } - port.onMessage.addListener(this.onMessage.bind(this, port)); + port.onMessage.addListener(this._onMessage.bind(this, port)); } - onMessage(port, {id, action, params, target, senderId}) { + _onMessage(port, {id, action, params, target, senderId}) { if (target !== this._source) { return; } - const handler = this._handlers.get(action); - if (typeof handler !== 'function') { return; } + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return; } + + const {handler, async} = messageHandler; - this.sendAck(port, id, senderId); + this._sendAck(port, id, senderId); + if (async) { + this._invokeHandlerAsync(handler, params, port, id, senderId); + } else { + this._invokeHandler(handler, params, port, id, senderId); + } + } + + _invokeHandler(handler, params, port, id, senderId) { + try { + const result = handler(params); + this._sendResult(port, id, senderId, {result}); + } catch (error) { + this._sendResult(port, id, senderId, {error: errorToJson(error)}); + } + } - handler(params).then( - (result) => { - this.sendResult(port, id, senderId, {result}); - }, - (error) => { - this.sendResult(port, id, senderId, {error: errorToJson(error)}); - }); + async _invokeHandlerAsync(handler, params, port, id, senderId) { + try { + const result = await handler(params); + this._sendResult(port, id, senderId, {result}); + } catch (error) { + this._sendResult(port, id, senderId, {error: errorToJson(error)}); + } } - sendAck(port, id, senderId) { + _sendAck(port, id, senderId) { port.postMessage({type: 'ack', id, senderId}); } - sendResult(port, id, senderId, data) { + _sendResult(port, id, senderId, data) { port.postMessage({type: 'result', id, senderId, data}); } } diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js index 1d539cab..4dcde638 100644 --- a/ext/fg/js/frontend-api-sender.js +++ b/ext/fg/js/frontend-api-sender.js @@ -17,97 +17,97 @@ class FrontendApiSender { - constructor() { - this.senderId = yomichan.generateId(16); - this.ackTimeout = 3000; // 3 seconds - this.responseTimeout = 10000; // 10 seconds - this.callbacks = new Map(); - this.disconnected = false; - this.nextId = 0; - - this.port = null; + constructor(target) { + this._target = target; + this._senderId = yomichan.generateId(16); + this._ackTimeout = 3000; // 3 seconds + this._responseTimeout = 10000; // 10 seconds + this._callbacks = new Map(); + this._disconnected = false; + this._nextId = 0; + this._port = null; } - invoke(action, params, target) { - if (this.disconnected) { + invoke(action, params) { + if (this._disconnected) { // attempt to reconnect the next time - this.disconnected = false; + this._disconnected = false; return Promise.reject(new Error('Disconnected')); } - if (this.port === null) { - this.createPort(); + if (this._port === null) { + this._createPort(); } - const id = `${this.nextId}`; - ++this.nextId; + const id = `${this._nextId}`; + ++this._nextId; return new Promise((resolve, reject) => { const info = {id, resolve, reject, ack: false, timer: null}; - this.callbacks.set(id, info); - info.timer = setTimeout(() => this.onError(id, 'Timeout (ack)'), this.ackTimeout); + this._callbacks.set(id, info); + info.timer = setTimeout(() => this._onError(id, 'Timeout (ack)'), this._ackTimeout); - this.port.postMessage({id, action, params, target, senderId: this.senderId}); + this._port.postMessage({id, action, params, target: this._target, senderId: this._senderId}); }); } - createPort() { - this.port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'}); - this.port.onDisconnect.addListener(this.onDisconnect.bind(this)); - this.port.onMessage.addListener(this.onMessage.bind(this)); + _createPort() { + this._port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'}); + this._port.onDisconnect.addListener(this._onDisconnect.bind(this)); + this._port.onMessage.addListener(this._onMessage.bind(this)); } - onMessage({type, id, data, senderId}) { - if (senderId !== this.senderId) { return; } + _onMessage({type, id, data, senderId}) { + if (senderId !== this._senderId) { return; } switch (type) { case 'ack': - this.onAck(id); + this._onAck(id); break; case 'result': - this.onResult(id, data); + this._onResult(id, data); break; } } - onDisconnect() { - this.disconnected = true; - this.port = null; + _onDisconnect() { + this._disconnected = true; + this._port = null; - for (const id of this.callbacks.keys()) { - this.onError(id, 'Disconnected'); + for (const id of this._callbacks.keys()) { + this._onError(id, 'Disconnected'); } } - onAck(id) { - const info = this.callbacks.get(id); + _onAck(id) { + const info = this._callbacks.get(id); if (typeof info === 'undefined') { - console.warn(`ID ${id} not found for ack`); + yomichan.logWarning(new Error(`ID ${id} not found for ack`)); return; } if (info.ack) { - console.warn(`Request ${id} already ack'd`); + yomichan.logWarning(new Error(`Request ${id} already ack'd`)); return; } info.ack = true; clearTimeout(info.timer); - info.timer = setTimeout(() => this.onError(id, 'Timeout (response)'), this.responseTimeout); + info.timer = setTimeout(() => this._onError(id, 'Timeout (response)'), this._responseTimeout); } - onResult(id, data) { - const info = this.callbacks.get(id); + _onResult(id, data) { + const info = this._callbacks.get(id); if (typeof info === 'undefined') { - console.warn(`ID ${id} not found`); + yomichan.logWarning(new Error(`ID ${id} not found`)); return; } if (!info.ack) { - console.warn(`Request ${id} not ack'd`); + yomichan.logWarning(new Error(`Request ${id} not ack'd`)); return; } - this.callbacks.delete(id); + this._callbacks.delete(id); clearTimeout(info.timer); info.timer = null; @@ -118,10 +118,10 @@ class FrontendApiSender { } } - onError(id, reason) { - const info = this.callbacks.get(id); + _onError(id, reason) { + const info = this._callbacks.get(id); if (typeof info === 'undefined') { return; } - this.callbacks.delete(id); + this._callbacks.delete(id); info.timer = null; info.reject(new Error(reason)); } diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index eecfe2e1..575dc413 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -25,73 +25,155 @@ * docSentenceExtract */ -class Frontend extends TextScanner { - constructor(popup) { - super( - window, - () => this.popup.isProxy() ? [] : [this.popup.getContainer()], - [(x, y) => this.popup.containsPoint(x, y)] - ); - - this.popup = popup; - +class Frontend { + constructor(popup, getUrl=null) { + this._id = yomichan.generateId(16); + this._popup = popup; + this._getUrl = getUrl; this._disabledOverride = false; - - this.options = null; - - this.optionsContext = { - depth: popup.depth, - url: popup.url - }; - + this._options = null; this._pageZoomFactor = 1.0; this._contentScale = 1.0; this._orphaned = false; this._lastShowPromise = Promise.resolve(); + this._enabledEventListeners = new EventListenerCollection(); + this._activeModifiers = new Set(); + this._optionsUpdatePending = false; + this._textScanner = new TextScanner({ + node: window, + ignoreElements: () => this._popup.isProxy() ? [] : [this._popup.getFrame()], + ignorePoint: (x, y) => this._popup.containsPoint(x, y), + search: this._search.bind(this) + }); this._windowMessageHandlers = new Map([ - ['popupClose', () => this.onSearchClear(true)], - ['selectionCopy', () => document.execCommand('copy')] + ['popupClose', this._onMessagePopupClose.bind(this)], + ['selectionCopy', this._onMessageSelectionCopy.bind()] ]); this._runtimeMessageHandlers = new Map([ - ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }], - ['rootPopupRequestInformationBroadcast', () => { this._broadcastRootPopupInformation(); }], - ['requestDocumentInformationBroadcast', ({uniqueId}) => { this._broadcastDocumentInformation(uniqueId); }] + ['popupSetVisibleOverride', this._onMessagePopupSetVisibleOverride.bind(this)], + ['rootPopupRequestInformationBroadcast', this._onMessageRootPopupRequestInformationBroadcast.bind(this)], + ['requestDocumentInformationBroadcast', this._onMessageRequestDocumentInformationBroadcast.bind(this)] ]); } + get canClearSelection() { + return this._textScanner.canClearSelection; + } + + set canClearSelection(value) { + this._textScanner.canClearSelection = value; + } + async prepare() { try { await this.updateOptions(); const {zoomFactor} = await apiGetZoom(); this._pageZoomFactor = zoomFactor; - window.addEventListener('resize', this.onResize.bind(this), false); + window.addEventListener('resize', this._onResize.bind(this), false); const visualViewport = window.visualViewport; if (visualViewport !== null && typeof visualViewport === 'object') { - window.visualViewport.addEventListener('scroll', this.onVisualViewportScroll.bind(this)); - window.visualViewport.addEventListener('resize', this.onVisualViewportResize.bind(this)); + window.visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this)); + window.visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this)); } - yomichan.on('orphaned', this.onOrphaned.bind(this)); + yomichan.on('orphaned', this._onOrphaned.bind(this)); yomichan.on('optionsUpdated', this.updateOptions.bind(this)); - yomichan.on('zoomChanged', this.onZoomChanged.bind(this)); - chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); + yomichan.on('zoomChanged', this._onZoomChanged.bind(this)); + chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this)); + + this._textScanner.on('clearSelection', this._onClearSelection.bind(this)); + this._textScanner.on('activeModifiersChanged', this._onActiveModifiersChanged.bind(this)); this._updateContentScale(); this._broadcastRootPopupInformation(); } catch (e) { - this.onError(e); + yomichan.logError(e); + } + } + + async setPopup(popup) { + this._textScanner.clearSelection(true); + this._popup = popup; + await popup.setOptionsContext(await this.getOptionsContext(), this._id); + } + + setDisabledOverride(disabled) { + this._disabledOverride = disabled; + this._updateTextScannerEnabled(); + } + + async setTextSource(textSource) { + await this._search(textSource, 'script'); + this._textScanner.setCurrentTextSource(textSource); + } + + async getOptionsContext() { + const url = this._getUrl !== null ? await this._getUrl() : window.location.href; + const depth = this._popup.depth; + const modifierKeys = [...this._activeModifiers]; + return {depth, url, modifierKeys}; + } + + async updateOptions() { + const optionsContext = await this.getOptionsContext(); + this._options = await apiOptionsGet(optionsContext); + this._textScanner.setOptions(this._options); + this._updateTextScannerEnabled(); + + const ignoreNodes = ['.scan-disable', '.scan-disable *']; + if (!this._options.scanning.enableOnPopupExpressions) { + ignoreNodes.push('.source-text', '.source-text *'); + } + this._textScanner.ignoreNodes = ignoreNodes.join(','); + + await this._popup.setOptionsContext(optionsContext, this._id); + + this._updateContentScale(); + + const textSourceCurrent = this._textScanner.getCurrentTextSource(); + const causeCurrent = this._textScanner.causeCurrent; + if (textSourceCurrent !== null && causeCurrent !== null) { + await this._search(textSourceCurrent, causeCurrent); } } - onResize() { + showContentCompleted() { + return this._lastShowPromise; + } + + // Message handlers + + _onMessagePopupClose() { + this._textScanner.clearSelection(false); + } + + _onMessageSelectionCopy() { + document.execCommand('copy'); + } + + _onMessagePopupSetVisibleOverride({visible}) { + this._popup.setVisibleOverride(visible); + } + + _onMessageRootPopupRequestInformationBroadcast() { + this._broadcastRootPopupInformation(); + } + + _onMessageRequestDocumentInformationBroadcast({uniqueId}) { + this._broadcastDocumentInformation(uniqueId); + } + + // Private + + _onResize() { this._updatePopupPosition(); } - onWindowMessage(e) { + _onWindowMessage(e) { const action = e.data; const handler = this._windowMessageHandlers.get(action); if (typeof handler !== 'function') { return false; } @@ -99,10 +181,7 @@ class Frontend extends TextScanner { handler(); } - onRuntimeMessage({action, params}, sender, callback) { - const {targetPopupId} = params || {}; - if (typeof targetPopupId !== 'undefined' && targetPopupId !== this.popup.id) { return; } - + _onRuntimeMessage({action, params}, sender, callback) { const handler = this._runtimeMessageHandlers.get(action); if (typeof handler !== 'function') { return false; } @@ -111,112 +190,78 @@ class Frontend extends TextScanner { return false; } - onOrphaned() { + _onOrphaned() { this._orphaned = true; } - onZoomChanged({newZoomFactor}) { + _onZoomChanged({newZoomFactor}) { this._pageZoomFactor = newZoomFactor; this._updateContentScale(); } - onVisualViewportScroll() { + _onVisualViewportScroll() { this._updatePopupPosition(); } - onVisualViewportResize() { + _onVisualViewportResize() { this._updateContentScale(); } - getMouseEventListeners() { - return [ - ...super.getMouseEventListeners(), - [window, 'message', this.onWindowMessage.bind(this)] - ]; - } - - setDisabledOverride(disabled) { - this._disabledOverride = disabled; - this.setEnabled(this.options.general.enable, this._canEnable()); + _onClearSelection({passive}) { + this._popup.hide(!passive); + this._popup.clearAutoPlayTimer(); + this._updatePendingOptions(); } - async setPopup(popup) { - this.onSearchClear(false); - this.popup = popup; - await popup.setOptions(this.options); - } - - async updateOptions() { - this.options = await apiOptionsGet(this.getOptionsContext()); - this.setOptions(this.options, this._canEnable()); - - const ignoreNodes = ['.scan-disable', '.scan-disable *']; - if (!this.options.scanning.enableOnPopupExpressions) { - ignoreNodes.push('.source-text', '.source-text *'); - } - this.ignoreNodes = ignoreNodes.join(','); - - await this.popup.setOptions(this.options); - - this._updateContentScale(); - - if (this.textSourceCurrent !== null && this.causeCurrent !== null) { - await this.onSearchSource(this.textSourceCurrent, this.causeCurrent); + async _onActiveModifiersChanged({modifiers}) { + if (areSetsEqual(modifiers, this._activeModifiers)) { return; } + this._activeModifiers = modifiers; + if (await this._popup.isVisible()) { + this._optionsUpdatePending = true; + return; } + await this.updateOptions(); } - async onSearchSource(textSource, cause) { + async _search(textSource, cause) { + await this._updatePendingOptions(); + let results = null; try { if (textSource !== null) { + const optionsContext = await this.getOptionsContext(); results = ( - await this.findTerms(textSource) || - await this.findKanji(textSource) + await this._findTerms(textSource, optionsContext) || + await this._findKanji(textSource, optionsContext) ); if (results !== null) { const focus = (cause === 'mouse'); - this.showContent(textSource, focus, results.definitions, results.type); + this._showContent(textSource, focus, results.definitions, results.type, optionsContext); } } } catch (e) { if (this._orphaned) { - if (textSource !== null && this.options.scanning.modifier !== 'none') { - this._showPopupContent(textSource, 'orphaned'); + if (textSource !== null && this._options.scanning.modifier !== 'none') { + this._showPopupContent(textSource, await this.getOptionsContext(), 'orphaned'); } } else { - this.onError(e); + yomichan.logError(e); } } finally { - if (results === null && this.options.scanning.autoHideResults) { - this.onSearchClear(true); + if (results === null && this._options.scanning.autoHideResults) { + this._textScanner.clearSelection(false); } } return results; } - showContent(textSource, focus, definitions, type) { - const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); - const url = window.location.href; - this._showPopupContent( - textSource, - type, - {definitions, context: {sentence, url, focus, disableHistory: true}} - ); - } - - showContentCompleted() { - return this._lastShowPromise; - } - - async findTerms(textSource) { - this.setTextSourceScanLength(textSource, this.options.scanning.length); - - const searchText = textSource.text(); + async _findTerms(textSource, optionsContext) { + const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length); if (searchText.length === 0) { return null; } - const {definitions, length} = await apiTermsFind(searchText, {}, this.getOptionsContext()); + const {definitions, length} = await apiTermsFind(searchText, {}, optionsContext); if (definitions.length === 0) { return null; } textSource.setEndOffset(length); @@ -224,82 +269,97 @@ class Frontend extends TextScanner { return {definitions, type: 'terms'}; } - async findKanji(textSource) { - this.setTextSourceScanLength(textSource, 1); - - const searchText = textSource.text(); + async _findKanji(textSource, optionsContext) { + const searchText = this._textScanner.getTextSourceContent(textSource, 1); if (searchText.length === 0) { return null; } - const definitions = await apiKanjiFind(searchText, this.getOptionsContext()); + const definitions = await apiKanjiFind(searchText, optionsContext); if (definitions.length === 0) { return null; } - return {definitions, type: 'kanji'}; - } + textSource.setEndOffset(1); - onSearchClear(changeFocus) { - this.popup.hide(changeFocus); - this.popup.clearAutoPlayTimer(); - super.onSearchClear(changeFocus); + return {definitions, type: 'kanji'}; } - getOptionsContext() { - this.optionsContext.url = this.popup.url; - return this.optionsContext; + _showContent(textSource, focus, definitions, type, optionsContext) { + const {url} = optionsContext; + const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt); + this._showPopupContent( + textSource, + optionsContext, + type, + {definitions, context: {sentence, url, focus, disableHistory: true}} + ); } - _showPopupContent(textSource, type=null, details=null) { - this._lastShowPromise = this.popup.showContent( + _showPopupContent(textSource, optionsContext, type=null, details=null) { + const context = {optionsContext, source: this._id}; + this._lastShowPromise = this._popup.showContent( textSource.getRect(), textSource.getWritingMode(), type, - details + details, + context ); return this._lastShowPromise; } + async _updatePendingOptions() { + if (this._optionsUpdatePending) { + this._optionsUpdatePending = false; + await this.updateOptions(); + } + } + + _updateTextScannerEnabled() { + const enabled = ( + this._options.general.enable && + this._popup.depth <= this._options.scanning.popupNestingMaxDepth && + !this._disabledOverride + ); + this._enabledEventListeners.removeAllEventListeners(); + this._textScanner.setEnabled(enabled); + if (enabled) { + this._enabledEventListeners.addEventListener(window, 'message', this._onWindowMessage.bind(this)); + } + } + _updateContentScale() { - const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this.options.general; + const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general; let contentScale = popupScalingFactor; if (popupScaleRelativeToPageZoom) { contentScale /= this._pageZoomFactor; } if (popupScaleRelativeToVisualViewport) { - contentScale /= Frontend._getVisualViewportScale(); + const visualViewport = window.visualViewport; + const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0); + contentScale /= visualViewportScale; } if (contentScale === this._contentScale) { return; } this._contentScale = contentScale; - this.popup.setContentScale(this._contentScale); + this._popup.setContentScale(this._contentScale); this._updatePopupPosition(); } + async _updatePopupPosition() { + const textSource = this._textScanner.getCurrentTextSource(); + if (textSource !== null && await this._popup.isVisible()) { + this._showPopupContent(textSource, await this.getOptionsContext()); + } + } + _broadcastRootPopupInformation() { - if (!this.popup.isProxy() && this.popup.depth === 0 && this.popup.frameId === 0) { - apiBroadcastTab('rootPopupInformation', {popupId: this.popup.id, frameId: this.popup.frameId}); + if (!this._popup.isProxy() && this._popup.depth === 0 && this._popup.frameId === 0) { + apiBroadcastTab('rootPopupInformation', {popupId: this._popup.id, frameId: this._popup.frameId}); } } _broadcastDocumentInformation(uniqueId) { apiBroadcastTab('documentInformationBroadcast', { uniqueId, - frameId: this.popup.frameId, + frameId: this._popup.frameId, title: document.title }); } - - _canEnable() { - return this.popup.depth <= this.options.scanning.popupNestingMaxDepth && !this._disabledOverride; - } - - async _updatePopupPosition() { - const textSource = this.getCurrentTextSource(); - if (textSource !== null && await this.popup.isVisible()) { - this._showPopupContent(textSource); - } - } - - static _getVisualViewportScale() { - const visualViewport = window.visualViewport; - return visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0; - } } diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-factory.js index 958462ff..b10acbaf 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-factory.js @@ -18,35 +18,31 @@ /* global * FrontendApiReceiver * Popup - * apiFrameInformationGet */ -class PopupProxyHost { - constructor() { +class PopupFactory { + constructor(frameId) { this._popups = new Map(); - this._apiReceiver = null; - this._frameId = null; + this._frameId = frameId; } // Public functions async prepare() { - const {frameId} = await apiFrameInformationGet(); - if (typeof frameId !== 'number') { return; } - this._frameId = frameId; - - this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${this._frameId}`, new Map([ - ['getOrCreatePopup', this._onApiGetOrCreatePopup.bind(this)], - ['setOptions', this._onApiSetOptions.bind(this)], - ['hide', this._onApiHide.bind(this)], - ['isVisible', this._onApiIsVisibleAsync.bind(this)], - ['setVisibleOverride', this._onApiSetVisibleOverride.bind(this)], - ['containsPoint', this._onApiContainsPoint.bind(this)], - ['showContent', this._onApiShowContent.bind(this)], - ['setCustomCss', this._onApiSetCustomCss.bind(this)], - ['clearAutoPlayTimer', this._onApiClearAutoPlayTimer.bind(this)], - ['setContentScale', this._onApiSetContentScale.bind(this)] + const apiReceiver = new FrontendApiReceiver(`popup-factory#${this._frameId}`, new Map([ + ['getOrCreatePopup', {async: false, handler: this._onApiGetOrCreatePopup.bind(this)}], + ['setOptionsContext', {async: true, handler: this._onApiSetOptionsContext.bind(this)}], + ['hide', {async: false, handler: this._onApiHide.bind(this)}], + ['isVisible', {async: true, handler: this._onApiIsVisibleAsync.bind(this)}], + ['setVisibleOverride', {async: true, handler: this._onApiSetVisibleOverride.bind(this)}], + ['containsPoint', {async: true, handler: this._onApiContainsPoint.bind(this)}], + ['showContent', {async: true, handler: this._onApiShowContent.bind(this)}], + ['setCustomCss', {async: false, handler: this._onApiSetCustomCss.bind(this)}], + ['clearAutoPlayTimer', {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}], + ['setContentScale', {async: false, handler: this._onApiSetContentScale.bind(this)}], + ['getUrl', {async: false, handler: this._onApiGetUrl.bind(this)}] ])); + apiReceiver.prepare(); } getOrCreatePopup(id=null, parentId=null, depth=null) { @@ -91,24 +87,25 @@ class PopupProxyHost { popup.setParent(parent); } this._popups.set(id, popup); + popup.prepare(); return popup; } // API message handlers - async _onApiGetOrCreatePopup({id, parentId}) { + _onApiGetOrCreatePopup({id, parentId}) { const popup = this.getOrCreatePopup(id, parentId); return { id: popup.id }; } - async _onApiSetOptions({id, options}) { + async _onApiSetOptionsContext({id, optionsContext, source}) { const popup = this._getPopup(id); - return await popup.setOptions(options); + return await popup.setOptionsContext(optionsContext, source); } - async _onApiHide({id, changeFocus}) { + _onApiHide({id, changeFocus}) { const popup = this._getPopup(id); return popup.hide(changeFocus); } @@ -125,32 +122,36 @@ class PopupProxyHost { async _onApiContainsPoint({id, x, y}) { const popup = this._getPopup(id); - [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, x, y); + [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y); return await popup.containsPoint(x, y); } - async _onApiShowContent({id, elementRect, writingMode, type, details}) { + async _onApiShowContent({id, elementRect, writingMode, type, details, context}) { const popup = this._getPopup(id); - elementRect = PopupProxyHost._convertJsonRectToDOMRect(popup, elementRect); - if (!PopupProxyHost._popupCanShow(popup)) { return; } - return await popup.showContent(elementRect, writingMode, type, details); + elementRect = this._convertJsonRectToDOMRect(popup, elementRect); + if (!this._popupCanShow(popup)) { return; } + return await popup.showContent(elementRect, writingMode, type, details, context); } - async _onApiSetCustomCss({id, css}) { + _onApiSetCustomCss({id, css}) { const popup = this._getPopup(id); return popup.setCustomCss(css); } - async _onApiClearAutoPlayTimer({id}) { + _onApiClearAutoPlayTimer({id}) { const popup = this._getPopup(id); return popup.clearAutoPlayTimer(); } - async _onApiSetContentScale({id, scale}) { + _onApiSetContentScale({id, scale}) { const popup = this._getPopup(id); return popup.setContentScale(scale); } + _onApiGetUrl() { + return window.location.href; + } + // Private functions _getPopup(id) { @@ -161,21 +162,21 @@ class PopupProxyHost { return popup; } - static _convertJsonRectToDOMRect(popup, jsonRect) { - const [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); + _convertJsonRectToDOMRect(popup, jsonRect) { + const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); return new DOMRect(x, y, jsonRect.width, jsonRect.height); } - static _convertPopupPointToRootPagePoint(popup, x, y) { + _convertPopupPointToRootPagePoint(popup, x, y) { if (popup.parent !== null) { - const popupRect = popup.parent.getContainerRect(); + const popupRect = popup.parent.getFrameRect(); x += popupRect.x; y += popupRect.y; } return [x, y]; } - static _popupCanShow(popup) { + _popupCanShow(popup) { return popup.parent === null || popup.parent.isVisibleSync(); } } diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index 82ad9a8f..82da839a 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -20,14 +20,13 @@ */ class PopupProxy { - constructor(id, depth, parentId, parentFrameId, url, getFrameOffset=null) { - this._parentId = parentId; - this._parentFrameId = parentFrameId; + constructor(id, depth, parentPopupId, parentFrameId, getFrameOffset=null, setDisabled=null) { this._id = id; this._depth = depth; - this._url = url; - this._apiSender = new FrontendApiSender(); + this._parentPopupId = parentPopupId; + this._apiSender = new FrontendApiSender(`popup-factory#${parentFrameId}`); this._getFrameOffset = getFrameOffset; + this._setDisabled = setDisabled; this._frameOffset = null; this._frameOffsetPromise = null; @@ -48,14 +47,10 @@ class PopupProxy { return this._depth; } - get url() { - return this._url; - } - // Public functions async prepare() { - const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId}); + const {id} = await this._invoke('getOrCreatePopup', {id: this._id, parentId: this._parentPopupId}); this._id = id; } @@ -63,20 +58,20 @@ class PopupProxy { return true; } - async setOptions(options) { - return await this._invokeHostApi('setOptions', {id: this._id, options}); + async setOptionsContext(optionsContext, source) { + return await this._invoke('setOptionsContext', {id: this._id, optionsContext, source}); } hide(changeFocus) { - this._invokeHostApi('hide', {id: this._id, changeFocus}); + this._invoke('hide', {id: this._id, changeFocus}); } async isVisible() { - return await this._invokeHostApi('isVisible', {id: this._id}); + return await this._invoke('isVisible', {id: this._id}); } setVisibleOverride(visible) { - this._invokeHostApi('setVisibleOverride', {id: this._id, visible}); + this._invoke('setVisibleOverride', {id: this._id, visible}); } async containsPoint(x, y) { @@ -84,38 +79,39 @@ class PopupProxy { await this._updateFrameOffset(); [x, y] = this._applyFrameOffset(x, y); } - return await this._invokeHostApi('containsPoint', {id: this._id, x, y}); + return await this._invoke('containsPoint', {id: this._id, x, y}); } - async showContent(elementRect, writingMode, type=null, details=null) { + async showContent(elementRect, writingMode, type, details, context) { let {x, y, width, height} = elementRect; if (this._getFrameOffset !== null) { await this._updateFrameOffset(); [x, y] = this._applyFrameOffset(x, y); } elementRect = {x, y, width, height}; - return await this._invokeHostApi('showContent', {id: this._id, elementRect, writingMode, type, details}); + return await this._invoke('showContent', {id: this._id, elementRect, writingMode, type, details, context}); } - async setCustomCss(css) { - return await this._invokeHostApi('setCustomCss', {id: this._id, css}); + setCustomCss(css) { + this._invoke('setCustomCss', {id: this._id, css}); } clearAutoPlayTimer() { - this._invokeHostApi('clearAutoPlayTimer', {id: this._id}); + this._invoke('clearAutoPlayTimer', {id: this._id}); + } + + setContentScale(scale) { + this._invoke('setContentScale', {id: this._id, scale}); } - async setContentScale(scale) { - this._invokeHostApi('setContentScale', {id: this._id, scale}); + async getUrl() { + return await this._invoke('getUrl', {}); } // Private - _invokeHostApi(action, params={}) { - if (typeof this._parentFrameId !== 'number') { - return Promise.reject(new Error('Invalid frame')); - } - return this._apiSender.invoke(action, params, `popup-proxy-host#${this._parentFrameId}`); + _invoke(action, params={}) { + return this._apiSender.invoke(action, params); } async _updateFrameOffset() { @@ -142,9 +138,13 @@ class PopupProxy { try { const offset = await this._frameOffsetPromise; this._frameOffset = offset !== null ? offset : [0, 0]; + if (offset === null && this._setDisabled !== null) { + this._setDisabled(); + return; + } this._frameOffsetUpdatedAt = now; } catch (e) { - logError(e); + yomichan.logError(e); } finally { this._frameOffsetPromise = null; } diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 42f08afa..b7d4b57e 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -16,8 +16,9 @@ */ /* global - * apiGetMessageToken - * apiInjectStylesheet + * DOM + * apiOptionsGet + * dynamicLoader */ class Popup { @@ -29,24 +30,24 @@ class Popup { this._child = null; this._childrenSupported = true; this._injectPromise = null; + this._injectPromiseComplete = false; this._visible = false; this._visibleOverride = null; this._options = null; + this._optionsContext = null; this._contentScale = 1.0; - this._containerSizeContentScale = null; this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); - this._messageToken = null; + this._previousOptionsContextSource = null; - this._container = document.createElement('iframe'); - this._container.className = 'yomichan-float'; - this._container.addEventListener('mousedown', (e) => e.stopPropagation()); - this._container.addEventListener('scroll', (e) => e.stopPropagation()); - this._container.style.width = '0px'; - this._container.style.height = '0px'; + this._frameSizeContentScale = null; + this._frameSecret = null; + this._frameToken = null; + this._frame = document.createElement('iframe'); + this._frame.className = 'yomichan-float'; + this._frame.style.width = '0'; + this._frame.style.height = '0'; this._fullscreenEventListeners = new EventListenerCollection(); - - this._updateVisibility(); } // Public properties @@ -71,19 +72,27 @@ class Popup { return this._frameId; } - get url() { - return window.location.href; - } - // Public functions + prepare() { + this._updateVisibility(); + this._frame.addEventListener('mousedown', (e) => e.stopPropagation()); + this._frame.addEventListener('scroll', (e) => e.stopPropagation()); + this._frame.addEventListener('load', this._onFrameLoad.bind(this)); + } + isProxy() { return false; } - async setOptions(options) { - this._options = options; + async setOptionsContext(optionsContext, source) { + this._optionsContext = optionsContext; + this._previousOptionsContextSource = source; + + this._options = await apiOptionsGet(optionsContext); this.updateTheme(); + + this._invokeApi('setOptionsContext', {optionsContext}); } hide(changeFocus) { @@ -111,7 +120,7 @@ class Popup { async containsPoint(x, y) { for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup._child) { - const rect = popup._container.getBoundingClientRect(); + const rect = popup._frame.getBoundingClientRect(); if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { return true; } @@ -119,14 +128,20 @@ class Popup { return false; } - async showContent(elementRect, writingMode, type=null, details=null) { + async showContent(elementRect, writingMode, type, details, context) { if (this._options === null) { throw new Error('Options not assigned'); } + + const {optionsContext, source} = context; + if (source !== this._previousOptionsContextSource) { + await this.setOptionsContext(optionsContext, source); + } + await this._show(elementRect, writingMode); if (type === null) { return; } this._invokeApi('setContent', {type, details}); } - async setCustomCss(css) { + setCustomCss(css) { this._invokeApi('setCustomCss', {css}); } @@ -160,82 +175,218 @@ class Popup { } updateTheme() { - this._container.dataset.yomichanTheme = this._options.general.popupOuterTheme; - this._container.dataset.yomichanSiteColor = this._getSiteColor(); + this._frame.dataset.yomichanTheme = this._options.general.popupOuterTheme; + this._frame.dataset.yomichanSiteColor = this._getSiteColor(); } async setCustomOuterCss(css, useWebExtensionApi) { - return await Popup._injectStylesheet( - 'yomichan-popup-outer-user-stylesheet', - 'code', - css, - useWebExtensionApi - ); + return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi); } setChildrenSupported(value) { this._childrenSupported = value; } - getContainer() { - return this._container; + getFrame() { + return this._frame; } - getContainerRect() { - return this._container.getBoundingClientRect(); + getFrameRect() { + return this._frame.getBoundingClientRect(); } // Private functions _inject() { - if (this._injectPromise === null) { - this._injectPromise = this._createInjectPromise(); + let injectPromise = this._injectPromise; + if (injectPromise === null) { + injectPromise = this._createInjectPromise(); + this._injectPromise = injectPromise; + injectPromise.then( + () => { + if (injectPromise !== this._injectPromise) { return; } + this._injectPromiseComplete = true; + }, + () => { this._resetFrame(); } + ); } - return this._injectPromise; + return injectPromise; + } + + _initializeFrame(frame, targetOrigin, frameId, setupFrame, timeout=10000) { + return new Promise((resolve, reject) => { + const tokenMap = new Map(); + let timer = null; + let frameLoadedResolve = null; + let frameLoadedReject = null; + const frameLoaded = new Promise((resolve2, reject2) => { + frameLoadedResolve = resolve2; + frameLoadedReject = reject2; + }); + + const postMessage = (action, params) => { + const contentWindow = frame.contentWindow; + if (contentWindow === null) { throw new Error('Frame missing content window'); } + + let validOrigin = true; + try { + validOrigin = (contentWindow.location.origin === targetOrigin); + } catch (e) { + // NOP + } + if (!validOrigin) { throw new Error('Unexpected frame origin'); } + + contentWindow.postMessage({action, params}, targetOrigin); + }; + + const onMessage = (message) => { + onMessageInner(message); + return false; + }; + + const onMessageInner = async (message) => { + try { + if (!isObject(message)) { return; } + const {action, params} = message; + if (!isObject(params)) { return; } + await frameLoaded; + if (timer === null) { return; } // Done + + switch (action) { + case 'popupPrepared': + { + const {secret} = params; + const token = yomichan.generateId(16); + tokenMap.set(secret, token); + postMessage('initialize', {secret, token, frameId}); + } + break; + case 'popupInitialized': + { + const {secret, token} = params; + const token2 = tokenMap.get(secret); + if (typeof token2 !== 'undefined' && token === token2) { + cleanup(); + resolve({secret, token}); + } + } + break; + } + } catch (e) { + cleanup(); + reject(e); + } + }; + + const onLoad = () => { + if (frameLoadedResolve === null) { + cleanup(); + reject(new Error('Unexpected load event')); + return; + } + + if (Popup.isFrameAboutBlank(frame)) { + return; + } + + frameLoadedResolve(); + frameLoadedResolve = null; + frameLoadedReject = null; + }; + + const cleanup = () => { + if (timer === null) { return; } // Done + clearTimeout(timer); + timer = null; + + frameLoadedResolve = null; + if (frameLoadedReject !== null) { + frameLoadedReject(new Error('Terminated')); + frameLoadedReject = null; + } + + chrome.runtime.onMessage.removeListener(onMessage); + frame.removeEventListener('load', onLoad); + }; + + // Start + timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + chrome.runtime.onMessage.addListener(onMessage); + frame.addEventListener('load', onLoad); + + // Prevent unhandled rejections + frameLoaded.catch(() => {}); // NOP + + setupFrame(frame); + }); } async _createInjectPromise() { - if (this._messageToken === null) { - this._messageToken = await apiGetMessageToken(); - } + this._injectStyles(); + + const {secret, token} = await this._initializeFrame(this._frame, this._targetOrigin, this._frameId, (frame) => { + frame.removeAttribute('src'); + frame.removeAttribute('srcdoc'); + this._observeFullscreen(true); + this._onFullscreenChanged(); + frame.contentDocument.location.href = chrome.runtime.getURL('/fg/float.html'); + }); + this._frameSecret = secret; + this._frameToken = token; + // Configure + const messageId = yomichan.generateId(16); const popupPreparedPromise = yomichan.getTemporaryListenerResult( chrome.runtime.onMessage, - ({action, params}, {resolve}) => { + (message, {resolve}) => { if ( - action === 'popupPrepareCompleted' && - isObject(params) && - params.targetPopupId === this._id + isObject(message) && + message.action === 'popupConfigured' && + isObject(message.params) && + message.params.messageId === messageId ) { resolve(); } } ); - - const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null); - this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html')); - this._container.addEventListener('load', () => { - this._invokeApi('prepare', { - popupInfo: { - id: this._id, - depth: this._depth, - parentFrameId - }, - url: this.url, - childrenSupported: this._childrenSupported, - scale: this._contentScale - }); + this._invokeApi('configure', { + messageId, + frameId: this._frameId, + popupId: this._id, + optionsContext: this._optionsContext, + childrenSupported: this._childrenSupported, + scale: this._contentScale }); - this._observeFullscreen(true); - this._onFullscreenChanged(); - this._injectStyles(); return popupPreparedPromise; } + _onFrameLoad() { + if (!this._injectPromiseComplete) { return; } + this._resetFrame(); + } + + _resetFrame() { + const parent = this._frame.parentNode; + if (parent !== null) { + parent.removeChild(this._frame); + } + this._frame.removeAttribute('src'); + this._frame.removeAttribute('srcdoc'); + + this._frameSecret = null; + this._frameToken = null; + this._injectPromise = null; + this._injectPromiseComplete = false; + } + async _injectStyles() { try { - await Popup._injectStylesheet('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); + await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); } catch (e) { // NOP } @@ -271,9 +422,9 @@ class Popup { } _onFullscreenChanged() { - const parent = (Popup._getFullscreenElement() || document.body || null); - if (parent !== null && this._container.parentNode !== parent) { - parent.appendChild(this._container); + const parent = this._getFrameParentElement(); + if (parent !== null && this._frame.parentNode !== parent) { + parent.appendChild(this._frame); } } @@ -281,31 +432,31 @@ class Popup { await this._inject(); const optionsGeneral = this._options.general; - const container = this._container; - const containerRect = container.getBoundingClientRect(); - const getPosition = ( - writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? - Popup._getPositionForHorizontalText : - Popup._getPositionForVerticalText - ); + const frame = this._frame; + const frameRect = frame.getBoundingClientRect(); - const viewport = Popup._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); + const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); const scale = this._contentScale; - const scaleRatio = this._containerSizeContentScale === null ? 1.0 : scale / this._containerSizeContentScale; - this._containerSizeContentScale = scale; - let [x, y, width, height, below] = getPosition( + const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale; + this._frameSizeContentScale = scale; + const getPositionArgs = [ elementRect, - Math.max(containerRect.width * scaleRatio, optionsGeneral.popupWidth * scale), - Math.max(containerRect.height * scaleRatio, optionsGeneral.popupHeight * scale), + Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale), + Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale), viewport, scale, optionsGeneral, writingMode + ]; + let [x, y, width, height, below] = ( + writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? + this._getPositionForHorizontalText(...getPositionArgs) : + this._getPositionForVerticalText(...getPositionArgs) ); const fullWidth = (optionsGeneral.popupDisplayMode === 'full-width'); - container.classList.toggle('yomichan-float-full-width', fullWidth); - container.classList.toggle('yomichan-float-above', !below); + frame.classList.toggle('yomichan-float-full-width', fullWidth); + frame.classList.toggle('yomichan-float-above', !below); if (optionsGeneral.popupDisplayMode === 'full-width') { x = viewport.left; @@ -313,10 +464,10 @@ class Popup { width = viewport.right - viewport.left; } - container.style.left = `${x}px`; - container.style.top = `${y}px`; - container.style.width = `${width}px`; - container.style.height = `${height}px`; + frame.style.left = `${x}px`; + frame.style.top = `${y}px`; + frame.style.width = `${width}px`; + frame.style.height = `${height}px`; this._setVisible(true); if (this._child !== null) { @@ -330,20 +481,20 @@ class Popup { } _updateVisibility() { - this._container.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important'); + this._frame.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important'); } _focusParent() { if (this._parent !== null) { // Chrome doesn't like focusing iframe without contentWindow. - const contentWindow = this._parent._container.contentWindow; + const contentWindow = this._parent.getFrame().contentWindow; if (contentWindow !== null) { contentWindow.focus(); } } else { // Firefox doesn't like focusing window without first blurring the iframe. - // this.container.contentWindow.blur() doesn't work on Firefox for some reason. - this._container.blur(); + // this._frame.contentWindow.blur() doesn't work on Firefox for some reason. + this._frame.blur(); // This is needed for Chrome. window.focus(); } @@ -351,36 +502,52 @@ class Popup { _getSiteColor() { const color = [255, 255, 255]; - Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.documentElement).backgroundColor)); - Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.body).backgroundColor)); + const {documentElement, body} = document; + if (documentElement !== null) { + this._addColor(color, window.getComputedStyle(documentElement).backgroundColor); + } + if (body !== null) { + this._addColor(color, window.getComputedStyle(body).backgroundColor); + } const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128); return dark ? 'dark' : 'light'; } _invokeApi(action, params={}) { - const token = this._messageToken; - const contentWindow = this._container.contentWindow; - if (token === null || contentWindow === null) { return; } + const secret = this._frameSecret; + const token = this._frameToken; + const contentWindow = this._frame.contentWindow; + if (secret === null || token === null || contentWindow === null) { return; } - contentWindow.postMessage({action, params, token}, this._targetOrigin); + contentWindow.postMessage({action, params, secret, token}, this._targetOrigin); } - static _getFullscreenElement() { - return ( - document.fullscreenElement || - document.msFullscreenElement || - document.mozFullScreenElement || - document.webkitFullscreenElement || - null - ); + _getFrameParentElement() { + const defaultParent = document.body; + const fullscreenElement = DOM.getFullscreenElement(); + if ( + fullscreenElement === null || + fullscreenElement.shadowRoot || + fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + ) { + return defaultParent; + } + + switch (fullscreenElement.nodeName.toUpperCase()) { + case 'IFRAME': + case 'FRAME': + return defaultParent; + } + + return fullscreenElement; } - static _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { + _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below'); const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale; const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale; - const [x, w] = Popup._getConstrainedPosition( + const [x, w] = this._getConstrainedPosition( elementRect.right - horizontalOffset, elementRect.left + horizontalOffset, width, @@ -388,7 +555,7 @@ class Popup { viewport.right, true ); - const [y, h, below] = Popup._getConstrainedPositionBinary( + const [y, h, below] = this._getConstrainedPositionBinary( elementRect.top - verticalOffset, elementRect.bottom + verticalOffset, height, @@ -399,12 +566,12 @@ class Popup { return [x, y, w, h, below]; } - static _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { - const preferRight = Popup._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); + _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { + const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale; const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale; - const [x, w] = Popup._getConstrainedPositionBinary( + const [x, w] = this._getConstrainedPositionBinary( elementRect.left - horizontalOffset, elementRect.right + horizontalOffset, width, @@ -412,7 +579,7 @@ class Popup { viewport.right, preferRight ); - const [y, h, below] = Popup._getConstrainedPosition( + const [y, h, below] = this._getConstrainedPosition( elementRect.bottom - verticalOffset, elementRect.top + verticalOffset, height, @@ -423,20 +590,22 @@ class Popup { return [x, y, w, h, below]; } - static _isVerticalTextPopupOnRight(positionPreference, writingMode) { + _isVerticalTextPopupOnRight(positionPreference, writingMode) { switch (positionPreference) { case 'before': - return !Popup._isWritingModeLeftToRight(writingMode); + return !this._isWritingModeLeftToRight(writingMode); case 'after': - return Popup._isWritingModeLeftToRight(writingMode); + return this._isWritingModeLeftToRight(writingMode); case 'left': return false; case 'right': return true; + default: + return false; } } - static _isWritingModeLeftToRight(writingMode) { + _isWritingModeLeftToRight(writingMode) { switch (writingMode) { case 'vertical-lr': case 'sideways-lr': @@ -446,7 +615,7 @@ class Popup { } } - static _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { size = Math.min(size, maxLimit - minLimit); let position; @@ -461,7 +630,7 @@ class Popup { return [position, size, after]; } - static _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { const overflowBefore = minLimit - (positionBefore - size); const overflowAfter = (positionAfter + size) - maxLimit; @@ -481,7 +650,10 @@ class Popup { return [position, size, after]; } - static _addColor(target, color) { + _addColor(target, cssColor) { + if (typeof cssColor !== 'string') { return; } + + const color = this._getColorInfo(cssColor); if (color === null) { return; } const a = color[3]; @@ -493,7 +665,7 @@ class Popup { } } - static _getColorInfo(cssColor) { + _getColorInfo(cssColor) { const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor); if (m === null) { return null; } @@ -506,7 +678,7 @@ class Popup { ]; } - static _getViewport(useVisualViewport) { + _getViewport(useVisualViewport) { const visualViewport = window.visualViewport; if (visualViewport !== null && typeof visualViewport === 'object') { const left = visualViewport.offsetLeft; @@ -531,87 +703,23 @@ class Popup { } } + const body = document.body; return { left: 0, top: 0, - right: document.body.clientWidth, + right: (body !== null ? body.clientWidth : 0), bottom: window.innerHeight }; } - static _isOnExtensionPage() { + static isFrameAboutBlank(frame) { try { - const url = chrome.runtime.getURL('/'); - return window.location.href.substring(0, url.length) === url; + const contentDocument = frame.contentDocument; + if (contentDocument === null) { return false; } + const url = contentDocument.location.href; + return /^about:blank(?:[#?]|$)/.test(url); } catch (e) { - // NOP - } - } - - static async _injectStylesheet(id, type, value, useWebExtensionApi) { - const injectedStylesheets = Popup._injectedStylesheets; - - if (Popup._isOnExtensionPage()) { - // Permissions error will occur if trying to use the WebExtension API to inject - // into an extension page. - useWebExtensionApi = false; - } - - let styleNode = injectedStylesheets.get(id); - if (typeof styleNode !== 'undefined') { - if (styleNode === null) { - // Previously injected via WebExtension API - throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); - } - } else { - styleNode = null; + return false; } - - if (useWebExtensionApi) { - // Inject via WebExtension API - if (styleNode !== null && styleNode.parentNode !== null) { - styleNode.parentNode.removeChild(styleNode); - } - - await apiInjectStylesheet(type, value); - - injectedStylesheets.set(id, null); - return null; - } - - // Create node in document - const parentNode = document.head; - if (parentNode === null) { - throw new Error('No parent node'); - } - - // Create or reuse node - const isFile = (type === 'file'); - const tagName = isFile ? 'link' : 'style'; - if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { - if (styleNode !== null && styleNode.parentNode !== null) { - styleNode.parentNode.removeChild(styleNode); - } - styleNode = document.createElement(tagName); - styleNode.id = id; - } - - // Update node style - if (isFile) { - styleNode.rel = value; - } else { - styleNode.textContent = value; - } - - // Update parent - if (styleNode.parentNode !== parentNode) { - parentNode.appendChild(styleNode); - } - - // Add to map - injectedStylesheets.set(id, styleNode); - return styleNode; } } - -Popup._injectedStylesheets = new Map(); diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index 3d9afe0f..fa4706f2 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -46,10 +46,14 @@ class TextSourceRange { return this.content; } - setEndOffset(length) { - const state = TextSourceRange.seekForward(this.range.startContainer, this.range.startOffset, length); + setEndOffset(length, fromEnd=false) { + const state = ( + fromEnd ? + TextSourceRange.seekForward(this.range.endContainer, this.range.endOffset, length) : + TextSourceRange.seekForward(this.range.startContainer, this.range.startOffset, length) + ); this.range.setEnd(state.node, state.offset); - this.content = state.content; + this.content = (fromEnd ? this.content + state.content : state.content); return length - state.remainder; } @@ -57,7 +61,7 @@ class TextSourceRange { const state = TextSourceRange.seekBackward(this.range.startContainer, this.range.startOffset, length); this.range.setStart(state.node, state.offset); this.rangeStartOffset = this.range.startOffset; - this.content = state.content; + this.content = state.content + this.content; return length - state.remainder; } @@ -94,7 +98,15 @@ class TextSourceRange { this.rangeStartOffset === other.rangeStartOffset ); } else { - return this.range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0; + try { + return this.range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0; + } catch (e) { + if (e.name === 'WrongDocumentError') { + // This can happen with shadow DOMs if the ranges are in different documents. + return false; + } + throw e; + } } } @@ -110,7 +122,8 @@ class TextSourceRange { return !( style.visibility === 'hidden' || style.display === 'none' || - parseFloat(style.fontSize) === 0); + parseFloat(style.fontSize) === 0 + ); } static getRubyElement(node) { @@ -345,13 +358,32 @@ class TextSourceRange { */ class TextSourceElement { - constructor(element, content='') { - this.element = element; - this.content = content; + constructor(element, fullContent=null, startOffset=0, endOffset=0) { + this._element = element; + this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element)); + this._startOffset = startOffset; + this._endOffset = endOffset; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + } + + get element() { + return this._element; + } + + get fullContent() { + return this._fullContent; + } + + get startOffset() { + return this._startOffset; + } + + get endOffset() { + return this._endOffset; } clone() { - return new TextSourceElement(this.element, this.content); + return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset); } cleanup() { @@ -359,44 +391,32 @@ class TextSourceElement { } text() { - return this.content; + return this._content; } - setEndOffset(length) { - switch (this.element.nodeName.toUpperCase()) { - case 'BUTTON': - this.content = this.element.textContent; - break; - case 'IMG': - this.content = this.element.getAttribute('alt'); - break; - default: - this.content = this.element.value; - break; - } - - let consumed = 0; - let content = ''; - for (const currentChar of this.content || '') { - if (consumed >= length) { - break; - } else if (!currentChar.match(IGNORE_TEXT_PATTERN)) { - consumed++; - content += currentChar; - } + setEndOffset(length, fromEnd=false) { + if (fromEnd) { + const delta = Math.min(this._fullContent.length - this._endOffset, length); + this._endOffset += delta; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + return delta; + } else { + const delta = Math.min(this._fullContent.length - this._startOffset, length); + this._endOffset = this._startOffset + delta; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + return delta; } - - this.content = content; - - return this.content.length; } - setStartOffset() { - return 0; + setStartOffset(length) { + const delta = Math.min(this._startOffset, length); + this._startOffset -= delta; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + return delta; } getRect() { - return this.element.getBoundingClientRect(); + return this._element.getBoundingClientRect(); } getWritingMode() { @@ -416,8 +436,30 @@ class TextSourceElement { typeof other === 'object' && other !== null && other instanceof TextSourceElement && - other.element === this.element && - other.content === this.content + this._element === other.element && + this._fullContent === other.fullContent && + this._startOffset === other.startOffset && + this._endOffset === other.endOffset ); } + + static getElementContent(element) { + let content; + switch (element.nodeName.toUpperCase()) { + case 'BUTTON': + content = element.textContent; + break; + case 'IMG': + content = element.getAttribute('alt') || ''; + break; + default: + content = `${element.value}`; + break; + } + + // Remove zero-width non-joiner + content = content.replace(/\u200c/g, ''); + + return content; + } } diff --git a/ext/manifest.json b/ext/manifest.json index 041827a1..f908da89 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,12 +1,29 @@ { "manifest_version": 2, "name": "Yomichan (testing)", - "version": "20.4.18.0", + "version": "20.5.22.0", "description": "Japanese dictionary with Anki integration (testing)", - "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"}, + "icons": { + "16": "mixed/img/icon16.png", + "19": "mixed/img/icon19.png", + "32": "mixed/img/icon32.png", + "38": "mixed/img/icon38.png", + "48": "mixed/img/icon48.png", + "64": "mixed/img/icon48.png", + "128": "mixed/img/icon128.png" + }, "browser_action": { - "default_icon": {"19": "mixed/img/icon19.png", "38": "mixed/img/icon38.png"}, + "default_icon": { + "16": "mixed/img/icon16.png", + "19": "mixed/img/icon19.png", + "32": "mixed/img/icon32.png", + "38": "mixed/img/icon38.png", + "48": "mixed/img/icon48.png", + "64": "mixed/img/icon48.png", + "128": "mixed/img/icon128.png" + }, + "default_title": "Yomichan", "default_popup": "bg/context.html" }, @@ -21,17 +38,18 @@ "mixed/js/core.js", "mixed/js/dom.js", "mixed/js/api.js", + "mixed/js/dynamic-loader.js", "mixed/js/text-scanner.js", "fg/js/document.js", "fg/js/frontend-api-sender.js", "fg/js/frontend-api-receiver.js", "fg/js/popup.js", "fg/js/source.js", + "fg/js/popup-factory.js", "fg/js/frame-offset-forwarder.js", "fg/js/popup-proxy.js", - "fg/js/popup-proxy-host.js", "fg/js/frontend.js", - "fg/js/frontend-initialize.js" + "fg/js/content-script-main.js" ], "match_about_blank": true, "all_frames": true diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css deleted file mode 100644 index e4549bbf..00000000 --- a/ext/mixed/css/display-dark.css +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2019-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the entrys 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/>. - */ - - -body { background-color: #1e1e1e; color: #d4d4d4; } - -h2 { border-bottom-color: #2f2f2f; } - -.navigation-header { - background-color: #1e1e1e; - border-bottom-color: #2f2f2f; -} - -.entry+.entry { border-top-color: #2f2f2f; } - -.kanji-glyph-data>tbody>tr>* { border-top-color: #3f3f3f; } - -.tag { color: #e1e1e1; } -.tag[data-category=default] { background-color: #69696e; } -.tag[data-category=name] { background-color: #489148; } -.tag[data-category=expression] { background-color: #b07f39; } -.tag[data-category=popular] { background-color: #025caa; } -.tag[data-category=frequent] { background-color: #4490a7; } -.tag[data-category=archaism] { background-color: #b04340; } -.tag[data-category=dictionary] { background-color: #9057ad; } -.tag[data-category=frequency] { background-color: #489148; } -.tag[data-category=partOfSpeech] { background-color: #565656; } -.tag[data-category=search] { background-color: #69696e; } -.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; } - -.term-reasons { color: #888888; } - -.term-expression>.term-expression-text .kanji-link { - border-bottom-color: #888888; - color: #cccccc; -} - -.term-expression[data-frequency=popular]>.term-expression-text, -.term-expression[data-frequency=popular]>.term-expression-text .kanji-link { - color: #0275d8; -} - -.term-expression[data-frequency=rare]>.term-expression-text, -.term-expression[data-frequency=rare]>.term-expression-text .kanji-link { - color: #666666; -} - -.term-definition-list, -.term-pitch-accent-group-list, -.term-pitch-accent-disambiguation-list, -.kanji-glossary-list { - color: #888888; -} - -.term-glossary, -.term-pitch-accent, -.kanji-glossary { - color: #d4d4d4; -} - -.icon-checkbox:checked + label { - /* invert colors */ - background-color: #d4d4d4; - color: #1e1e1e; -} - -.term-pitch-accent-container { border-bottom-color: #2f2f2f; } - -.term-pitch-accent-character:before { border-color: #ffffff; } - -.term-pitch-accent-graph-line, -.term-pitch-accent-graph-line-tail, -#term-pitch-accent-graph-dot, -#term-pitch-accent-graph-dot-downstep, -#term-pitch-accent-graph-triangle { - stroke: #ffffff; -} - -#term-pitch-accent-graph-dot, -#term-pitch-accent-graph-dot-downstep>circle:last-of-type { - fill: #ffffff; -} diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css deleted file mode 100644 index 7bcb1014..00000000 --- a/ext/mixed/css/display-default.css +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2019-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the entrys 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/>. - */ - - -body { background-color: #ffffff; color: #333333; } - -h2 { border-bottom-color: #eeeeee; } - -.navigation-header { - background-color: #ffffff; - border-bottom-color: #eeeeee; -} - -.entry+.entry { border-top-color: #eeeeee; } - -.kanji-glyph-data>tbody>tr>* { border-top-color: #dddddd; } - -.tag { color: #ffffff; } -.tag[data-category=default] { background-color: #8a8a91; } -.tag[data-category=name] { background-color: #5cb85c; } -.tag[data-category=expression] { background-color: #f0ad4e; } -.tag[data-category=popular] { background-color: #0275d8; } -.tag[data-category=frequent] { background-color: #5bc0de; } -.tag[data-category=archaism] { background-color: #d9534f; } -.tag[data-category=dictionary] { background-color: #aa66cc; } -.tag[data-category=frequency] { background-color: #5cb85c; } -.tag[data-category=partOfSpeech] { background-color: #565656; } -.tag[data-category=search] { background-color: #8a8a91; } -.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; } - -.term-reasons { color: #777777; } - -.term-expression>.term-expression-text .kanji-link { - border-bottom-color: #777777; - color: #333333; -} - -.term-expression[data-frequency=popular]>.term-expression-text, -.term-expression[data-frequency=popular]>.term-expression-text .kanji-link { - color: #0275d8; -} - -.term-expression[data-frequency=rare]>.term-expression-text, -.term-expression[data-frequency=rare]>.term-expression-text .kanji-link { - color: #999999; -} - -.term-definition-list, -.term-pitch-accent-group-list, -.term-pitch-accent-disambiguation-list, -.kanji-glossary-list { - color: #777777; -} - -.term-glossary, -.term-pitch-accent, -.kanji-glossary { - color: #000000; -} - -.icon-checkbox:checked + label { - /* invert colors */ - background-color: #333333; - color: #ffffff; -} - -.term-pitch-accent-container { border-bottom-color: #eeeeee; } - -.term-pitch-accent-character:before { border-color: #000000; } - -.term-pitch-accent-graph-line, -.term-pitch-accent-graph-line-tail, -#term-pitch-accent-graph-dot, -#term-pitch-accent-graph-dot-downstep, -#term-pitch-accent-graph-triangle { - stroke: #000000; -} - -#term-pitch-accent-graph-dot, -#term-pitch-accent-graph-dot-downstep>circle:last-of-type { - fill: #000000; -} diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index d1a54064..8b567173 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -15,6 +15,72 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/* + * Variables + */ + +:root { + --background-color: #ffffff; + --glossary-image-background-color: #eeeeee; + + --dark-text-color: #000000; + --default-text-color: #333333; + --light-text-color: #777777; + --very-light-text-color: #999999; + + --light-border-color: #eeeeee; + --medium-border-color: #dddddd; + --dark-border-color: #777777; + + --popuplar-kanji-text-color: #0275d8; + + --pitch-accent-annotation-color: #000000; + + --tag-text-color: #ffffff; + --tag-default-background-color: #8a8a91; + --tag-name-background-color: #5cb85c; + --tag-expression-background-color: #f0ad4e; + --tag-popular-background-color: #0275d8; + --tag-frequent-background-color: #5bc0de; + --tag-archaism-background-color: #d9534f; + --tag-dictionary-background-color: #aa66cc; + --tag-frequency-background-color: #5cb85c; + --tag-part-of-speech-background-color: #565656; + --tag-search-background-color: #8a8a91; + --tag-pitch-accent-dictionary-background-color: #6640be; +} + +:root[data-yomichan-theme=dark] { + --background-color: #1e1e1e; + --glossary-image-background-color: #2f2f2f; + + --dark-text-color: #d8d8d8; + --default-text-color: #d4d4d4; + --light-text-color: #888888; + --very-light-text-color: #666666; + + --light-border-color: #2f2f2f; + --medium-border-color: #3f3f3f; + --dark-border-color: #888888; + + --popuplar-kanji-text-color: #0275d8; + + --pitch-accent-annotation-color: #ffffff; + + --tag-text-color: #e1e1e1; + --tag-default-background-color: #69696e; + --tag-name-background-color: #489148; + --tag-expression-background-color: #b07f39; + --tag-popular-background-color: #025caa; + --tag-frequent-background-color: #4490a7; + --tag-archaism-background-color: #b04340; + --tag-dictionary-background-color: #9057ad; + --tag-frequency-background-color: #489148; + --tag-part-of-speech-background-color: #565656; + --tag-search-background-color: #69696e; + --tag-pitch-accent-dictionary-background-color: #6640be; +} + /* * Fonts @@ -25,6 +91,7 @@ src: url('/mixed/ttf/kanji-stroke-orders.ttf'); } + /* * General */ @@ -45,6 +112,8 @@ body { border: 0; padding: 0; overflow-y: scroll; /* always show scroll bar */ + background-color: var(--background-color); + color: var(--default-text-color); } ol, ul { @@ -68,10 +137,10 @@ h2 { font-size: 1.25em; font-weight: normal; margin: 0.25em 0 0; - border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */ - border-bottom-style: solid; + border-bottom: 0.05714285714285714em solid var(--light-border-color); /* 14px * 1.25em => 1px */ } + /* * Navigation */ @@ -83,8 +152,8 @@ h2 { height: 2.1em; box-sizing: border-box; padding: 0.25em 0.5em; - border-bottom-width: 0.07142857em; /* 14px => 1px */ - border-bottom-style: solid; + border-bottom: 0.07142857em solid var(--light-border-color); /* 14px => 1px */ + background-color: var(--background-color); z-index: 10; } @@ -131,6 +200,12 @@ h2 { user-select: none; } +.icon-checkbox:checked+label { + /* Invert colors */ + background-color: var(--default-text-color); + color: var(--background-color); +} + #query-parser-content { margin-top: 0.5em; font-size: 2em; @@ -206,11 +281,21 @@ button.action-button { } .term-expression .kanji-link { - border-bottom-width: 0.03571428em; /* 28px => 1px */ - border-bottom-style: dashed; + border-bottom: 0.03571428em dashed var(--dark-border-color); /* 28px => 1px */ + color: var(--default-text-color); text-decoration: none; } +.term-expression[data-frequency=popular]>.term-expression-text, +.term-expression[data-frequency=popular]>.term-expression-text .kanji-link { + color: var(--popuplar-kanji-text-color); +} + +.term-expression[data-frequency=rare]>.term-expression-text, +.term-expression[data-frequency=rare]>.term-expression-text .kanji-link { + color: var(--very-light-text-color); +} + .entry:not(.entry-current) .current { display: none; } @@ -225,6 +310,48 @@ button.action-button { white-space: nowrap; vertical-align: baseline; border-radius: 0.25em; + color: var(--tag-text-color); + background-color: var(--tag-default-background-color); +} + +.tag[data-category=name] { + background-color: var(--tag-name-background-color); +} + +.tag[data-category=expression] { + background-color: var(--tag-expression-background-color); +} + +.tag[data-category=popular] { + background-color: var(--tag-popular-background-color); +} + +.tag[data-category=frequent] { + background-color: var(--tag-frequent-background-color); +} + +.tag[data-category=archaism] { + background-color: var(--tag-archaism-background-color); +} + +.tag[data-category=dictionary] { + background-color: var(--tag-dictionary-background-color); +} + +.tag[data-category=frequency] { + background-color: var(--tag-frequency-background-color); +} + +.tag[data-category=partOfSpeech] { + background-color: var(--tag-part-of-speech-background-color); +} + +.tag[data-category=search] { + background-color: var(--tag-search-background-color); +} + +.tag[data-category=pitch-accent-dictionary] { + background-color: var(--tag-pitch-accent-dictionary-background-color); } .tag-inner { @@ -249,8 +376,7 @@ button.action-button { } .entry+.entry { - border-top-width: 0.07142857em; /* 14px => 1px */ - border-top-style: solid; + border-top: 0.07142857em solid var(--light-border-color); /* 14px => 1px */ } .entry[data-type=term][data-expression-multi=true] .actions>.action-play-audio { @@ -259,6 +385,7 @@ button.action-button { .term-reasons { display: inline-block; + color: var(--light-text-color); } .term-reasons>.term-reason+.term-reason-separator+.term-reason:before { @@ -346,6 +473,7 @@ button.action-button { margin: 0; padding: 0; list-style-type: none; + color: var(--light-text-color); } .term-definition-list:not([data-count="0"]):not([data-count="1"]) { @@ -364,6 +492,10 @@ button.action-button { list-style-type: circle; } +.term-glossary { + color: var(--dark-text-color); +} + .term-definition-disambiguation-list[data-count="0"] { display: none; } @@ -445,8 +577,7 @@ button.action-button { } .term-pitch-accent-container { - border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */ - border-bottom-style: solid; + border-bottom: 0.05714285714285714em solid var(--light-border-color); /* 14px * 1.25em => 1px */ padding-bottom: 0.25em; margin-bottom: 0.25em; } @@ -455,6 +586,7 @@ button.action-button { margin: 0; padding: 0; list-style-type: none; + color: var(--light-text-color); } .term-pitch-accent-group-list:not([data-count="0"]):not([data-count="1"]) { @@ -478,6 +610,7 @@ button.action-button { .term-pitch-accent { display: inline; line-height: 1.5em; + color: var(--dark-text-color); } .term-pitch-accent-list:not([data-count="0"]):not([data-count="1"])>.term-pitch-accent { @@ -490,6 +623,7 @@ button.action-button { .term-pitch-accent-disambiguation-list { padding-right: 0.25em; + color: var(--light-text-color); } .term-pitch-accent-disambiguation-list:before { @@ -522,6 +656,9 @@ button.action-button { display: inline-block; position: relative; } +.term-pitch-accent-character:before { + border-color: var(--pitch-accent-annotation-color); +} .term-pitch-accent-character[data-pitch='high']:before { content: ""; display: block; @@ -586,33 +723,155 @@ button.action-button { .term-pitch-accent-graph-line, .term-pitch-accent-graph-line-tail { fill: none; - stroke: #000000; + stroke: var(--pitch-accent-annotation-color); stroke-width: 5; } .term-pitch-accent-graph-line-tail { stroke-dasharray: 5 5; } #term-pitch-accent-graph-dot { - fill: #000000; - stroke: #000000; + fill: var(--pitch-accent-annotation-color); + stroke: var(--pitch-accent-annotation-color); stroke-width: 5; } #term-pitch-accent-graph-dot-downstep { fill: none; - stroke: #000000; + stroke: var(--pitch-accent-annotation-color); stroke-width: 5; } #term-pitch-accent-graph-dot-downstep>circle:last-of-type { - fill: #000000; + fill: var(--pitch-accent-annotation-color); } #term-pitch-accent-graph-triangle { fill: none; - stroke: #000000; + stroke: var(--pitch-accent-annotation-color); stroke-width: 5; } /* + * Glossary images + */ + +.term-glossary-image-container { + display: inline-block; + white-space: nowrap; + max-width: 100%; + position: relative; + vertical-align: top; + line-height: 0; + font-size: 0.07142857em; /* 14px => 1px */ + overflow: hidden; + background-color: var(--glossary-image-background-color); +} + +.term-glossary-image-link { + cursor: inherit; + color: inherit; +} + +.term-glossary-image-link[href]:hover { + cursor: pointer; +} + +.term-glossary-image-container-overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + font-size: 14em; /* 1px => 14px; */ + line-height: 1.42857143; /* 14px => 20px */ + display: table; + table-layout: fixed; + white-space: normal; + color: var(--light-text-color); +} + +.term-glossary-item[data-has-image=true][data-image-load-state=load-error] .term-glossary-image-container-overlay:after { + content: "Image failed to load"; + display: table-cell; + width: 100%; + height: 100%; + vertical-align: middle; + text-align: center; + padding: 0.25em; +} + +.term-glossary-image { + display: inline-block; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + vertical-align: top; + object-fit: contain; + border: none; + outline: none; +} + +.term-glossary-image:not([src]) { + display: none; +} + +.term-glossary-image[data-pixelated=true] { + image-rendering: auto; + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.term-glossary-image-aspect-ratio-sizer { + content: ""; + display: inline-block; + width: 0; + vertical-align: top; + font-size: 0; +} + +.term-glossary-image-link-text:before { + content: "["; +} + +.term-glossary-image-link-text:after { + content: "]"; +} + +:root[data-compact-glossaries=true] .term-glossary-image-container { + display: none; + position: absolute; + left: 0; + top: 100%; + z-index: 1; +} + +:root[data-compact-glossaries=true] .entry:nth-last-of-type(1):not(:nth-of-type(1)) .term-glossary-image-container { + bottom: 100%; + top: auto; +} + +:root[data-compact-glossaries=true] .term-glossary-image-link { + position: relative; + display: inline-block; +} + +:root[data-compact-glossaries=true] .term-glossary-image-link:hover .term-glossary-image-container, +:root[data-compact-glossaries=true] .term-glossary-image-link:focus .term-glossary-image-container { + display: block; +} + +:root:not([data-compact-glossaries=true]) .term-glossary-image-link-text { + display: none; +} + +:root:not([data-compact-glossaries=true]) .term-glossary-image-description { + display: block; +} + + +/* * Kanji */ @@ -631,8 +890,7 @@ button.action-button { } .kanji-glyph-data>tbody>tr>* { - border-top-width: 0.07142857em; /* 14px => 1px */ - border-top-style: solid; + border-top: 0.07142857em solid var(--medium-border-color); /* 14px => 1px */ text-align: left; vertical-align: top; padding: 0.36em; @@ -668,9 +926,14 @@ button.action-button { margin: 0; padding: 0; list-style-type: none; + color: var(--light-text-color); } .kanji-glossary-list:not([data-count="0"]):not([data-count="1"]) { padding-left: 1.4em; list-style-type: decimal; } + +.kanji-glossary { + color: var(--dark-text-color); +} diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html index 3baa8293..fc0558a9 100644 --- a/ext/mixed/display-templates.html +++ b/ext/mixed/display-templates.html @@ -35,6 +35,7 @@ </li></template> <template id="term-definition-disambiguation-template"><span class="term-definition-disambiguation"></span></template> <template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary-separator"> </span><span class="term-glossary"></span></li></template> +<template id="term-glossary-item-image-template"><li class="term-glossary-item" data-has-image="true"><span class="term-glossary-separator"> </span><span class="term-glossary"><a class="term-glossary-image-link" target="_blank" rel="noreferrer noopener"><span class="term-glossary-image-container"><span class="term-glossary-image-aspect-ratio-sizer"></span><img class="term-glossary-image" alt="" /><span class="term-glossary-image-container-overlay"></span></span><span class="term-glossary-image-link-text">Image</span></a> <span class="term-glossary-image-description"></span></span></li></template> <template id="term-reason-template"><span class="term-reason"></span><span class="term-reason-separator"> </span></template> <template id="term-pitch-accent-static-template"><svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> diff --git a/ext/mixed/img/icon32.png b/ext/mixed/img/icon32.png Binary files differnew file mode 100644 index 00000000..05f2f064 --- /dev/null +++ b/ext/mixed/img/icon32.png diff --git a/ext/mixed/img/yomichan-icon.svg b/ext/mixed/img/yomichan-icon.svg new file mode 100644 index 00000000..f15ab0aa --- /dev/null +++ b/ext/mixed/img/yomichan-icon.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> + <rect width="16" height="16" rx="1.625" ry="1.625"/> + <path d="m2 2v2h3v3h-3v2h3v3h-3v2h5v-12h-5zm7 0v2h5v-2h-5zm0 5v2h5v-2h-5zm0 5v2h5v-2h-5z" fill="#fff"/> +</svg>
\ No newline at end of file diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 30c08347..0bc91759 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -28,10 +28,6 @@ function apiOptionsGetFull() { return _apiInvoke('optionsGetFull'); } -function apiOptionsSet(changedOptions, optionsContext, source) { - return _apiInvoke('optionsSet', {changedOptions, optionsContext, source}); -} - function apiOptionsSave(source) { return _apiInvoke('optionsSave', {source}); } @@ -64,8 +60,8 @@ function apiTemplateRender(template, data) { return _apiInvoke('templateRender', {data, template}); } -function apiAudioGetUri(definition, source, optionsContext) { - return _apiInvoke('audioGetUri', {definition, source, optionsContext}); +function apiAudioGetUri(definition, source, details) { + return _apiInvoke('audioGetUri', {definition, source, details}); } function apiCommandExec(command, params) { @@ -76,6 +72,10 @@ function apiScreenshotGet(options) { return _apiInvoke('screenshotGet', {options}); } +function apiSendMessageToFrame(frameId, action, params) { + return _apiInvoke('sendMessageToFrame', {frameId, action, params}); +} + function apiBroadcastTab(action, params) { return _apiInvoke('broadcastTab', {action, params}); } @@ -108,14 +108,176 @@ function apiGetZoom() { return _apiInvoke('getZoom'); } -function apiGetMessageToken() { - return _apiInvoke('getMessageToken'); -} - function apiGetDefaultAnkiFieldTemplates() { return _apiInvoke('getDefaultAnkiFieldTemplates'); } +function apiGetAnkiDeckNames() { + return _apiInvoke('getAnkiDeckNames'); +} + +function apiGetAnkiModelNames() { + return _apiInvoke('getAnkiModelNames'); +} + +function apiGetAnkiModelFieldNames(modelName) { + return _apiInvoke('getAnkiModelFieldNames', {modelName}); +} + +function apiGetDictionaryInfo() { + return _apiInvoke('getDictionaryInfo'); +} + +function apiGetDictionaryCounts(dictionaryNames, getTotal) { + return _apiInvoke('getDictionaryCounts', {dictionaryNames, getTotal}); +} + +function apiPurgeDatabase() { + return _apiInvoke('purgeDatabase'); +} + +function apiGetMedia(targets) { + return _apiInvoke('getMedia', {targets}); +} + +function apiLog(error, level, context) { + return _apiInvoke('log', {error, level, context}); +} + +function apiLogIndicatorClear() { + return _apiInvoke('logIndicatorClear'); +} + +function apiImportDictionaryArchive(archiveContent, details, onProgress) { + return _apiInvokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress); +} + +function apiDeleteDictionary(dictionaryName, onProgress) { + return _apiInvokeWithProgress('deleteDictionary', {dictionaryName}, onProgress); +} + +function apiModifySettings(targets, source) { + return _apiInvoke('modifySettings', {targets, source}); +} + +function _apiCreateActionPort(timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let portNameResolve; + let portNameReject; + const portNamePromise = new Promise((resolve2, reject2) => { + portNameResolve = resolve2; + portNameReject = reject2; + }); + + const onConnect = async (port) => { + try { + const portName = await portNamePromise; + if (port.name !== portName || timer === null) { return; } + } catch (e) { + return; + } + + clearTimeout(timer); + timer = null; + + chrome.runtime.onConnect.removeListener(onConnect); + resolve(port); + }; + + const onError = (e) => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + chrome.runtime.onConnect.removeListener(onConnect); + portNameReject(e); + reject(e); + }; + + timer = setTimeout(() => onError(new Error('Timeout')), timeout); + + chrome.runtime.onConnect.addListener(onConnect); + _apiInvoke('createActionPort').then(portNameResolve, onError); + }); +} + +function _apiInvokeWithProgress(action, params, onProgress, timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let port = null; + + if (typeof onProgress !== 'function') { + onProgress = () => {}; + } + + const onMessage = (message) => { + switch (message.type) { + case 'ack': + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + break; + case 'progress': + try { + onProgress(...message.data); + } catch (e) { + // NOP + } + break; + case 'complete': + cleanup(); + resolve(message.data); + break; + case 'error': + cleanup(); + reject(jsonToError(message.data)); + break; + } + }; + + const onDisconnect = () => { + cleanup(); + reject(new Error('Disconnected')); + }; + + const cleanup = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + if (port !== null) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + port = null; + } + onProgress = null; + }; + + timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + (async () => { + try { + port = await _apiCreateActionPort(timeout); + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + port.postMessage({action, params}); + } catch (e) { + cleanup(); + reject(e); + } finally { + action = null; + params = null; + } + })(); + }); +} + function _apiInvoke(action, params={}) { const data = {action, params}; return new Promise((resolve, reject) => { @@ -143,3 +305,17 @@ function _apiInvoke(action, params={}) { function _apiCheckLastError() { // NOP } + +let _apiForwardLogsToBackendEnabled = false; +function apiForwardLogsToBackend() { + if (_apiForwardLogsToBackendEnabled) { return; } + _apiForwardLogsToBackendEnabled = true; + + yomichan.on('log', async ({error, level, context}) => { + try { + await apiLog(errorToJson(error), level, context); + } catch (e) { + // NOP + } + }); +} diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js index 45b733fc..fdfb0b10 100644 --- a/ext/mixed/js/audio-system.js +++ b/ext/mixed/js/audio-system.js @@ -40,7 +40,7 @@ class TextToSpeechAudio { } } - play() { + async play() { try { if (this._utterance === null) { this._utterance = new SpeechSynthesisUtterance(this.text || ''); @@ -66,10 +66,10 @@ class TextToSpeechAudio { } class AudioSystem { - constructor({getAudioUri}) { - this._cache = new Map(); + constructor({audioUriBuilder, useCache}) { + this._cache = useCache ? new Map() : null; this._cacheSizeMaximum = 32; - this._getAudioUri = getAudioUri; + this._audioUriBuilder = audioUriBuilder; if (typeof speechSynthesis !== 'undefined') { // speechSynthesis.getVoices() will not be populated unless some API call is made. @@ -79,21 +79,35 @@ class AudioSystem { async getDefinitionAudio(definition, sources, details) { const key = `${definition.expression}:${definition.reading}`; - const cacheValue = this._cache.get(definition); - if (typeof cacheValue !== 'undefined') { - const {audio, uri, source} = cacheValue; - return {audio, uri, source}; + const hasCache = (this._cache !== null && !details.disableCache); + + if (hasCache) { + const cacheValue = this._cache.get(key); + if (typeof cacheValue !== 'undefined') { + const {audio, uri, source} = cacheValue; + const index = sources.indexOf(source); + if (index >= 0) { + return {audio, uri, index}; + } + } } - for (const source of sources) { + for (let i = 0, ii = sources.length; i < ii; ++i) { + const source = sources[i]; const uri = await this._getAudioUri(definition, source, details); if (uri === null) { continue; } try { - const audio = await this._createAudio(uri, details); - this._cacheCheck(); - this._cache.set(key, {audio, uri, source}); - return {audio, uri, source}; + const audio = ( + details.binary ? + await this._createAudioBinary(uri) : + await this._createAudio(uri) + ); + if (hasCache) { + this._cacheCheck(); + this._cache.set(key, {audio, uri, source}); + } + return {audio, uri, index: i}; } catch (e) { // NOP } @@ -102,7 +116,7 @@ class AudioSystem { throw new Error('Could not create audio'); } - createTextToSpeechAudio({text, voiceUri}) { + createTextToSpeechAudio(text, voiceUri) { const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri); if (voice === null) { throw new Error('Invalid text-to-speech voice'); @@ -114,27 +128,38 @@ class AudioSystem { // NOP } - async _createAudio(uri, details) { + _getAudioUri(definition, source, details) { + return ( + this._audioUriBuilder !== null ? + this._audioUriBuilder.getUri(definition, source, details) : + null + ); + } + + async _createAudio(uri) { const ttsParameters = this._getTextToSpeechParameters(uri); if (ttsParameters !== null) { - if (typeof details === 'object' && details !== null) { - if (details.tts === false) { - throw new Error('Text-to-speech not permitted'); - } - } - return this.createTextToSpeechAudio(ttsParameters); + const {text, voiceUri} = ttsParameters; + return this.createTextToSpeechAudio(text, voiceUri); } return await this._createAudioFromUrl(uri); } + async _createAudioBinary(uri) { + const ttsParameters = this._getTextToSpeechParameters(uri); + if (ttsParameters !== null) { + throw new Error('Cannot create audio from text-to-speech'); + } + + return await this._createAudioBinaryFromUrl(uri); + } + _createAudioFromUrl(url) { return new Promise((resolve, reject) => { const audio = new Audio(url); audio.addEventListener('loadeddata', () => { - const duration = audio.duration; - if (duration === 5.694694 || duration === 5.720718) { - // Hardcoded values for invalid audio + if (!this._isAudioValid(audio)) { reject(new Error('Could not retrieve audio')); } else { resolve(audio); @@ -144,6 +169,42 @@ class AudioSystem { }); } + _createAudioBinaryFromUrl(url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = 'arraybuffer'; + xhr.addEventListener('load', async () => { + const arrayBuffer = xhr.response; + if (!await this._isAudioBinaryValid(arrayBuffer)) { + reject(new Error('Could not retrieve audio')); + } else { + resolve(arrayBuffer); + } + }); + xhr.addEventListener('error', () => reject(new Error('Failed to connect'))); + xhr.open('GET', url); + xhr.send(); + }); + } + + _isAudioValid(audio) { + const duration = audio.duration; + return ( + duration !== 5.694694 && // jpod101 invalid audio (Chrome) + duration !== 5.720718 // jpod101 invalid audio (Firefox) + ); + } + + async _isAudioBinaryValid(arrayBuffer) { + const digest = await AudioSystem.arrayBufferDigest(arrayBuffer); + switch (digest) { + case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // jpod101 invalid audio + return false; + default: + return true; + } + } + _getTextToSpeechVoiceFromVoiceUri(voiceUri) { try { for (const voice of speechSynthesis.getVoices()) { @@ -181,4 +242,13 @@ class AudioSystem { this._cache.delete(key); } } + + static async arrayBufferDigest(arrayBuffer) { + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer))); + let digest = ''; + for (const byte of hash) { + digest += byte.toString(16).padStart(2, '0'); + } + return digest; + } } diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 2d11c11a..589425f2 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -52,15 +52,28 @@ if (EXTENSION_IS_BROWSER_EDGE) { */ function errorToJson(error) { + try { + if (isObject(error)) { + return { + name: error.name, + message: error.message, + stack: error.stack, + data: error.data + }; + } + } catch (e) { + // NOP + } return { - name: error.name, - message: error.message, - stack: error.stack, - data: error.data + value: error, + hasValue: true }; } function jsonToError(jsonError) { + if (jsonError.hasValue) { + return jsonError.value; + } const error = new Error(jsonError.message); error.name = jsonError.name; error.stack = jsonError.stack; @@ -68,28 +81,6 @@ function jsonToError(jsonError) { return error; } -function logError(error, alert) { - const manifest = chrome.runtime.getManifest(); - let errorMessage = `${manifest.name} v${manifest.version} has encountered an error.\n`; - errorMessage += `Originating URL: ${window.location.href}\n`; - - const errorString = `${error.toString ? error.toString() : error}`; - const stack = `${error.stack}`.trimRight(); - 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'; - - console.error(errorMessage); - - if (alert) { - window.alert(`${errorString}\n\nCheck the developer console for more details.`); - } -} - /* * Common helpers @@ -103,6 +94,11 @@ function hasOwn(object, property) { return Object.prototype.hasOwnProperty.call(object, property); } +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions +function escapeRegExp(string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); +} + // toIterable is required on Edge for cross-window origin objects. function toIterable(value) { if (typeof Symbol !== 'undefined' && typeof value[Symbol.iterator] !== 'undefined') { @@ -155,6 +151,12 @@ function getSetIntersection(set1, set2) { return result; } +function getSetDifference(set1, set2) { + return new Set( + [...set1].filter((value) => !set2.has(value)) + ); +} + /* * Async utilities @@ -316,6 +318,15 @@ const yomichan = (() => { this.trigger('orphaned', {error}); } + isExtensionUrl(url) { + try { + const urlBase = chrome.runtime.getURL('/'); + return url.substring(0, urlBase.length) === urlBase; + } catch (e) { + return false; + } + } + getTemporaryListenerResult(eventHandler, userCallback, timeout=null) { if (!( typeof eventHandler.addListener === 'function' && @@ -352,8 +363,77 @@ const yomichan = (() => { }); } + logWarning(error) { + this.log(error, 'warn'); + } + + logError(error) { + this.log(error, 'error'); + } + + log(error, level, context=null) { + if (!isObject(context)) { + context = this._getLogContext(); + } + + let errorString; + try { + errorString = error.toString(); + if (/^\[object \w+\]$/.test(errorString)) { + errorString = JSON.stringify(error); + } + } catch (e) { + errorString = `${error}`; + } + + let errorStack; + try { + errorStack = (typeof error.stack === 'string' ? error.stack.trimRight() : ''); + } catch (e) { + errorStack = ''; + } + + let errorData; + try { + errorData = error.data; + } catch (e) { + // NOP + } + + if (errorStack.startsWith(errorString)) { + errorString = errorStack; + } else if (errorStack.length > 0) { + errorString += `\n${errorStack}`; + } + + const manifest = chrome.runtime.getManifest(); + let message = `${manifest.name} v${manifest.version} has encountered a problem.`; + message += `\nOriginating URL: ${context.url}\n`; + message += errorString; + if (typeof errorData !== 'undefined') { + message += `\nData: ${JSON.stringify(errorData, null, 4)}`; + } + message += '\n\nIssues can be reported at https://github.com/FooSoft/yomichan/issues'; + + switch (level) { + case 'info': console.info(message); break; + case 'debug': console.debug(message); break; + case 'warn': console.warn(message); break; + case 'error': console.error(message); break; + default: console.log(message); break; + } + + this.trigger('log', {error, level, context}); + } + // Private + _getLogContext() { + return { + url: window.location.href + }; + } + _onMessage({action, params}, sender, callback) { const handler = this._messageHandlers.get(action); if (typeof handler !== 'function') { return false; } diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index 0f991362..a2b2b139 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -22,7 +22,8 @@ */ class DisplayGenerator { - constructor() { + constructor({mediaLoader}) { + this._mediaLoader = mediaLoader; this._templateHandler = null; this._termPitchAccentStaticTemplateIsSetup = false; } @@ -176,16 +177,30 @@ class DisplayGenerator { const onlyListContainer = node.querySelector('.term-definition-disambiguation-list'); const glossaryContainer = node.querySelector('.term-glossary-list'); - node.dataset.dictionary = details.dictionary; + const dictionary = details.dictionary; + node.dataset.dictionary = dictionary; this._appendMultiple(tagListContainer, this._createTag.bind(this), details.definitionTags); this._appendMultiple(onlyListContainer, this._createTermDisambiguation.bind(this), details.only); - this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary); + this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary, dictionary); return node; } - _createTermGlossaryItem(glossary) { + _createTermGlossaryItem(glossary, dictionary) { + if (typeof glossary === 'string') { + return this._createTermGlossaryItemText(glossary); + } else if (typeof glossary === 'object' && glossary !== null) { + switch (glossary.type) { + case 'image': + return this._createTermGlossaryItemImage(glossary, dictionary); + } + } + + return null; + } + + _createTermGlossaryItemText(glossary) { const node = this._templateHandler.instantiate('term-glossary-item'); const container = node.querySelector('.term-glossary'); if (container !== null) { @@ -194,6 +209,68 @@ class DisplayGenerator { return node; } + _createTermGlossaryItemImage(data, dictionary) { + const {path, width, height, preferredWidth, preferredHeight, title, description, pixelated} = data; + + const usedWidth = ( + typeof preferredWidth === 'number' ? + preferredWidth : + width + ); + const aspectRatio = ( + typeof preferredWidth === 'number' && + typeof preferredHeight === 'number' ? + preferredWidth / preferredHeight : + width / height + ); + + const node = this._templateHandler.instantiate('term-glossary-item-image'); + node.dataset.path = path; + node.dataset.dictionary = dictionary; + node.dataset.imageLoadState = 'not-loaded'; + + const imageContainer = node.querySelector('.term-glossary-image-container'); + imageContainer.style.width = `${usedWidth}em`; + if (typeof title === 'string') { + imageContainer.title = title; + } + + const aspectRatioSizer = node.querySelector('.term-glossary-image-aspect-ratio-sizer'); + aspectRatioSizer.style.paddingTop = `${aspectRatio * 100.0}%`; + + const image = node.querySelector('img.term-glossary-image'); + const imageLink = node.querySelector('.term-glossary-image-link'); + image.dataset.pixelated = `${pixelated === true}`; + + if (this._mediaLoader !== null) { + this._mediaLoader.loadMedia( + path, + dictionary, + (url) => this._setImageData(node, image, imageLink, url, false), + () => this._setImageData(node, image, imageLink, null, true) + ); + } + + if (typeof description === 'string') { + const container = node.querySelector('.term-glossary-image-description'); + this._appendMultilineText(container, description); + } + + return node; + } + + _setImageData(container, image, imageLink, url, unloaded) { + if (url !== null) { + image.src = url; + imageLink.href = url; + container.dataset.imageLoadState = 'loaded'; + } else { + image.removeAttribute('src'); + imageLink.removeAttribute('href'); + container.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error'; + } + } + _createTermDisambiguation(disambiguation) { const node = this._templateHandler.instantiate('term-definition-disambiguation'); node.dataset.term = disambiguation; diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 63687dc2..2e59b4ff 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -20,6 +20,7 @@ * DOM * DisplayContext * DisplayGenerator + * MediaLoader * WindowScroll * apiAudioGetUri * apiBroadcastTab @@ -45,7 +46,14 @@ class Display { this.index = 0; this.audioPlaying = null; this.audioFallback = null; - this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)}); + this.audioSystem = new AudioSystem({ + audioUriBuilder: { + getUri: async (definition, source, details) => { + return await apiAudioGetUri(definition, source, details); + } + }, + useCache: true + }); this.styleNode = null; this.eventListeners = new EventListenerCollection(); @@ -55,12 +63,13 @@ class Display { this.clickScanPrevent = false; this.setContentToken = null; - this.displayGenerator = new DisplayGenerator(); + this.mediaLoader = new MediaLoader(); + this.displayGenerator = new DisplayGenerator({mediaLoader: this.mediaLoader}); this.windowScroll = new WindowScroll(); this._onKeyDownHandlers = new Map([ ['Escape', () => { - this.onSearchClear(); + this.onEscape(); return true; }], ['PageUp', (e) => { @@ -168,15 +177,13 @@ class Display { async prepare() { await yomichan.prepare(); await this.displayGenerator.prepare(); - await this.updateOptions(); - yomichan.on('optionsUpdated', () => this.updateOptions()); } onError(_error) { throw new Error('Override me'); } - onSearchClear() { + onEscape() { throw new Error('Override me'); } @@ -331,7 +338,7 @@ class Display { } onKeyDown(e) { - const key = Display.getKeyFromEvent(e); + const key = DOM.getKeyFromEvent(e); const handler = this._onKeyDownHandlers.get(key); if (typeof handler === 'function') { if (handler(e)) { @@ -392,12 +399,6 @@ class Display { updateTheme(themeName) { document.documentElement.dataset.yomichanTheme = themeName; - - const stylesheets = document.querySelectorAll('link[data-yomichan-theme-name]'); - for (const stylesheet of stylesheets) { - const match = (stylesheet.dataset.yomichanThemeName === themeName); - stylesheet.rel = (match ? 'stylesheet' : 'stylesheet alternate'); - } } setCustomCss(css) { @@ -472,6 +473,8 @@ class Display { const token = {}; // Unique identifier token this.setContentToken = token; try { + this.mediaLoader.unloadAll(); + switch (type) { case 'terms': await this.setContentTerms(details.definitions, details.context, token); @@ -784,16 +787,14 @@ class Display { const expression = expressionIndex === -1 ? definition : definition.expressions[expressionIndex]; - if (this.audioPlaying !== null) { - this.audioPlaying.pause(); - this.audioPlaying = null; - } + this._stopPlayingAudio(); - const sources = this.options.audio.sources; - let audio, source, info; + let audio, info; try { - ({audio, source} = await this.audioSystem.getDefinitionAudio(expression, sources)); - info = `From source ${1 + sources.indexOf(source)}: ${source}`; + const {sources, textToSpeechVoice, customSourceUrl} = this.options.audio; + let index; + ({audio, index} = await this.audioSystem.getDefinitionAudio(expression, sources, {textToSpeechVoice, customSourceUrl})); + info = `From source ${1 + index}: ${sources[index]}`; } catch (e) { if (this.audioFallback === null) { this.audioFallback = new Audio('/mixed/mp3/button.mp3'); @@ -802,7 +803,7 @@ class Display { info = 'Could not find audio'; } - const button = this.audioButtonFindImage(entryIndex); + const button = this.audioButtonFindImage(entryIndex, expressionIndex); if (button !== null) { let titleDefault = button.dataset.titleDefault; if (!titleDefault) { @@ -812,10 +813,19 @@ class Display { button.title = `${titleDefault}\n${info}`; } + this._stopPlayingAudio(); + this.audioPlaying = audio; audio.currentTime = 0; audio.volume = this.options.audio.volume / 100.0; - audio.play(); + const playPromise = audio.play(); + if (typeof playPromise !== 'undefined') { + try { + await playPromise; + } catch (e2) { + // NOP + } + } } catch (e) { this.onError(e); } finally { @@ -823,6 +833,13 @@ class Display { } } + _stopPlayingAudio() { + if (this.audioPlaying !== null) { + this.audioPlaying.pause(); + this.audioPlaying = null; + } + } + noteUsesScreenshot(mode) { const optionsAnki = this.options.anki; const fields = (mode === 'kanji' ? optionsAnki.kanji : optionsAnki.terms).fields; @@ -901,9 +918,16 @@ class Display { viewerButton.dataset.noteId = noteId; } - audioButtonFindImage(index) { + audioButtonFindImage(index, expressionIndex) { const entry = this.getEntry(index); - return entry !== null ? entry.querySelector('.action-play-audio>img') : null; + if (entry === null) { return null; } + + const container = ( + expressionIndex >= 0 ? + entry.querySelector(`.term-expression:nth-of-type(${expressionIndex + 1})`) : + entry + ); + return container !== null ? container.querySelector('.action-play-audio>img') : null; } async getDefinitionsAddable(definitions, modes) { @@ -934,11 +958,6 @@ class Display { return elementRect.top - documentRect.top; } - static getKeyFromEvent(event) { - const key = event.key; - return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); - } - async _getNoteContext() { const documentTitle = await this.getDocumentTitle(); return { @@ -947,9 +966,4 @@ class Display { } }; } - - async _getAudioUri(definition, source) { - const optionsContext = this.getOptionsContext(); - return await apiAudioGetUri(definition, source, optionsContext); - } } diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js index 03acbb80..0e8f4462 100644 --- a/ext/mixed/js/dom.js +++ b/ext/mixed/js/dom.js @@ -62,4 +62,28 @@ class DOM { default: return false; } } + + static getActiveModifiers(event) { + const modifiers = new Set(); + if (event.altKey) { modifiers.add('alt'); } + if (event.ctrlKey) { modifiers.add('ctrl'); } + if (event.metaKey) { modifiers.add('meta'); } + if (event.shiftKey) { modifiers.add('shift'); } + return modifiers; + } + + static getKeyFromEvent(event) { + const key = event.key; + return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); + } + + static getFullscreenElement() { + return ( + document.fullscreenElement || + document.msFullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + null + ); + } } diff --git a/ext/mixed/js/dynamic-loader-sentinel.js b/ext/mixed/js/dynamic-loader-sentinel.js new file mode 100644 index 00000000..f783bdb7 --- /dev/null +++ b/ext/mixed/js/dynamic-loader-sentinel.js @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2020 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/>. + */ + +yomichan.trigger('dynamicLoaderSentinel', {script: document.currentScript}); diff --git a/ext/mixed/js/dynamic-loader.js b/ext/mixed/js/dynamic-loader.js new file mode 100644 index 00000000..ce946109 --- /dev/null +++ b/ext/mixed/js/dynamic-loader.js @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2020 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 + * apiInjectStylesheet + */ + +const dynamicLoader = (() => { + const injectedStylesheets = new Map(); + + async function loadStyle(id, type, value, useWebExtensionApi=false) { + if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) { + // Permissions error will occur if trying to use the WebExtension API to inject into an extension page + useWebExtensionApi = false; + } + + let styleNode = injectedStylesheets.get(id); + if (typeof styleNode !== 'undefined') { + if (styleNode === null) { + // Previously injected via WebExtension API + throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); + } + } else { + styleNode = null; + } + + if (useWebExtensionApi) { + // Inject via WebExtension API + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + + injectedStylesheets.set(id, null); + await apiInjectStylesheet(type, value); + return null; + } + + // Create node in document + const parentNode = document.head; + if (parentNode === null) { + throw new Error('No parent node'); + } + + // Create or reuse node + const isFile = (type === 'file'); + const tagName = isFile ? 'link' : 'style'; + if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + styleNode = document.createElement(tagName); + } + + // Update node style + if (isFile) { + styleNode.rel = 'stylesheet'; + styleNode.href = value; + } else { + styleNode.textContent = value; + } + + // Update parent + if (styleNode.parentNode !== parentNode) { + parentNode.appendChild(styleNode); + } + + // Add to map + injectedStylesheets.set(id, styleNode); + return styleNode; + } + + function loadScripts(urls) { + return new Promise((resolve, reject) => { + const parent = document.body; + if (parent === null) { + reject(new Error('Missing body')); + return; + } + + for (const url of urls) { + const node = parent.querySelector(`script[src='${escapeCSSAttribute(url)}']`); + if (node !== null) { continue; } + + const script = document.createElement('script'); + script.async = false; + script.src = url; + parent.appendChild(script); + } + + loadScriptSentinel(parent, resolve, reject); + }); + } + + function loadScriptSentinel(parent, resolve, reject) { + const script = document.createElement('script'); + + const sentinelEventName = 'dynamicLoaderSentinel'; + const sentinelEventCallback = (e) => { + if (e.script !== script) { return; } + yomichan.off(sentinelEventName, sentinelEventCallback); + parent.removeChild(script); + resolve(); + }; + yomichan.on(sentinelEventName, sentinelEventCallback); + + try { + script.async = false; + script.src = '/mixed/js/dynamic-loader-sentinel.js'; + parent.appendChild(script); + } catch (e) { + yomichan.off(sentinelEventName, sentinelEventCallback); + reject(e); + } + } + + function escapeCSSAttribute(value) { + return value.replace(/['\\]/g, (character) => `\\${character}`); + } + + + return { + loadStyle, + loadScripts + }; +})(); diff --git a/ext/mixed/js/environment.js b/ext/mixed/js/environment.js new file mode 100644 index 00000000..e5bc20a7 --- /dev/null +++ b/ext/mixed/js/environment.js @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2020 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 Environment { + constructor() { + this._cachedEnvironmentInfo = null; + } + + async prepare() { + this._cachedEnvironmentInfo = await this._loadEnvironmentInfo(); + } + + getInfo() { + if (this._cachedEnvironmentInfo === null) { throw new Error('Not prepared'); } + return this._cachedEnvironmentInfo; + } + + async _loadEnvironmentInfo() { + const browser = await this._getBrowser(); + const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); + const modifierInfo = this._getModifierInfo(browser, platform.os); + return { + browser, + platform: { + os: platform.os + }, + modifiers: modifierInfo + }; + } + + async _getBrowser() { + if (EXTENSION_IS_BROWSER_EDGE) { + return 'edge'; + } + if (typeof browser !== 'undefined') { + try { + const info = await browser.runtime.getBrowserInfo(); + if (info.name === 'Fennec') { + return 'firefox-mobile'; + } + } catch (e) { + // NOP + } + return 'firefox'; + } else { + return 'chrome'; + } + } + + _getModifierInfo(browser, os) { + let osKeys; + let separator; + switch (os) { + case 'win': + separator = ' + '; + osKeys = [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Windows'] + ]; + break; + case 'mac': + separator = ''; + osKeys = [ + ['alt', '⌥'], + ['ctrl', '⌃'], + ['shift', '⇧'], + ['meta', '⌘'] + ]; + break; + case 'linux': + case 'openbsd': + case 'cros': + case 'android': + separator = ' + '; + osKeys = [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Super'] + ]; + break; + default: + throw new Error(`Invalid OS: ${os}`); + } + + const isFirefox = (browser === 'firefox' || browser === 'firefox-mobile'); + const keys = []; + + for (const [value, name] of osKeys) { + // Firefox doesn't support event.metaKey on platforms other than macOS + if (value === 'meta' && isFirefox && os !== 'mac') { continue; } + keys.push({value, name}); + } + + return {keys, separator}; + } +} diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js index 79d69946..801dec84 100644 --- a/ext/mixed/js/japanese.js +++ b/ext/mixed/js/japanese.js @@ -16,6 +16,11 @@ */ const jp = (() => { + const ITERATION_MARK_CODE_POINT = 0x3005; + const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063; + const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3; + const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc; + const HIRAGANA_RANGE = [0x3040, 0x309f]; const KATAKANA_RANGE = [0x30a0, 0x30ff]; const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE]; @@ -65,20 +70,65 @@ const jp = (() => { const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ')); + const HALFWIDTH_KATAKANA_MAPPING = new Map([ + ['ヲ', 'ヲヺ-'], + ['ァ', 'ァ--'], + ['ィ', 'ィ--'], + ['ゥ', 'ゥ--'], + ['ェ', 'ェ--'], + ['ォ', 'ォ--'], + ['ャ', 'ャ--'], + ['ュ', 'ュ--'], + ['ョ', 'ョ--'], + ['ッ', 'ッ--'], + ['ー', 'ー--'], + ['ア', 'ア--'], + ['イ', 'イ--'], + ['ウ', 'ウヴ-'], + ['エ', 'エ--'], + ['オ', 'オ--'], + ['カ', 'カガ-'], + ['キ', 'キギ-'], + ['ク', 'クグ-'], + ['ケ', 'ケゲ-'], + ['コ', 'コゴ-'], + ['サ', 'サザ-'], + ['シ', 'シジ-'], + ['ス', 'スズ-'], + ['セ', 'セゼ-'], + ['ソ', 'ソゾ-'], + ['タ', 'タダ-'], + ['チ', 'チヂ-'], + ['ツ', 'ツヅ-'], + ['テ', 'テデ-'], + ['ト', 'トド-'], + ['ナ', 'ナ--'], + ['ニ', 'ニ--'], + ['ヌ', 'ヌ--'], + ['ネ', 'ネ--'], + ['ノ', 'ノ--'], + ['ハ', 'ハバパ'], + ['ヒ', 'ヒビピ'], + ['フ', 'フブプ'], + ['ヘ', 'ヘベペ'], + ['ホ', 'ホボポ'], + ['マ', 'マ--'], + ['ミ', 'ミ--'], + ['ム', 'ム--'], + ['メ', 'メ--'], + ['モ', 'モ--'], + ['ヤ', 'ヤ--'], + ['ユ', 'ユ--'], + ['ヨ', 'ヨ--'], + ['ラ', 'ラ--'], + ['リ', 'リ--'], + ['ル', 'ル--'], + ['レ', 'レ--'], + ['ロ', 'ロ--'], + ['ワ', 'ワ--'], + ['ン', 'ン--'] + ]); - // 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) { @@ -89,59 +139,410 @@ const jp = (() => { return false; } + function getWanakana() { + try { + if (typeof wanakana !== 'undefined') { + // eslint-disable-next-line no-undef + return wanakana; + } + } catch (e) { + // NOP + } + return null; + } + + + 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); + } - // String testing functions + isCodePointJapanese(codePoint) { + return isCodePointInRanges(codePoint, JAPANESE_RANGES); + } + + // String testing functions - function isStringEntirelyKana(str) { - if (str.length === 0) { return false; } - for (const c of str) { - if (!isCodePointKana(c.codePointAt(0))) { - return false; + isStringEntirelyKana(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (!isCodePointInRanges(c.codePointAt(0), KANA_RANGES)) { + return false; + } } + return true; } - return true; - } - function isStringPartiallyJapanese(str) { - if (str.length === 0) { return false; } - for (const c of str) { - if (isCodePointJapanese(c.codePointAt(0))) { - 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; } - return false; - } + // Mora functions - // Mora functions + isMoraPitchHigh(moraIndex, pitchAccentPosition) { + switch (pitchAccentPosition) { + case 0: return (moraIndex > 0); + case 1: return (moraIndex < 1); + default: return (moraIndex > 0 && moraIndex < pitchAccentPosition); + } + } - function isMoraPitchHigh(moraIndex, pitchAccentPosition) { - return pitchAccentPosition === 0 ? (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 - 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); + convertKatakanaToHiragana(text) { + const wanakana = this._getWanakana(); + let result = ''; + for (const c of text) { + if (wanakana.isKatakana(c)) { + result += wanakana.toHiragana(c); + } else { + result += c; + } } + + return result; } - return morae; - } + convertHiraganaToKatakana(text) { + const wanakana = this._getWanakana(); + let result = ''; + for (const c of text) { + if (wanakana.isHiragana(c)) { + result += wanakana.toKatakana(c); + } else { + result += c; + } + } + + 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) { + const fallback = [{furigana: reading, text: expression}]; + if (!reading) { + return fallback; + } + + let isAmbiguous = false; + const segmentize = (reading2, groups) => { + if (groups.length === 0 || isAmbiguous) { + return []; + } + + const group = groups[0]; + if (group.mode === 'kana') { + if (this.convertKatakanaToHiragana(reading2).startsWith(this.convertKatakanaToHiragana(group.text))) { + const readingLeft = reading2.substring(group.text.length); + const segs = segmentize(readingLeft, groups.splice(1)); + if (segs) { + return [{text: group.text, furigana: ''}].concat(segs); + } + } + } else { + let foundSegments = null; + for (let i = reading2.length; i >= group.text.length; --i) { + const readingUsed = reading2.substring(0, i); + const readingLeft = reading2.substring(i); + const segs = segmentize(readingLeft, groups.slice(1)); + if (segs) { + if (foundSegments !== null) { + // more than one way to segmentize the tail, mark as ambiguous + isAmbiguous = true; + return null; + } + foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs); + } + // there is only one way to segmentize the last non-kana group + if (groups.length === 1) { + break; + } + } + return foundSegments; + } + }; + + const groups = []; + let modePrev = null; + for (const c of expression) { + const codePoint = c.codePointAt(0); + const modeCurr = this.isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT ? 'kanji' : 'kana'; + if (modeCurr === modePrev) { + groups[groups.length - 1].text += c; + } else { + groups.push({mode: modeCurr, text: c}); + modePrev = modeCurr; + } + } + + const segments = segmentize(reading, groups); + if (segments && !isAmbiguous) { + return segments; + } + return fallback; + } + + distributeFuriganaInflected(expression, reading, source) { + const output = []; + + 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 + ); + for (const segment of this.distributeFurigana(stemExpression, stemReading)) { + output.push(segment); + } + + if (stemLength !== source.length) { + output.push({text: source.substring(stemLength), furigana: ''}); + } + + return output; + } + + // 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 + + _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; + } + } - // Exports - return { - isCodePointKanji, - isCodePointKana, - isCodePointJapanese, - isStringEntirelyKana, - isStringPartiallyJapanese, - isMoraPitchHigh, - getKanaMorae - }; + return new JapaneseUtil(getWanakana()); })(); diff --git a/ext/mixed/js/media-loader.js b/ext/mixed/js/media-loader.js new file mode 100644 index 00000000..64ccd715 --- /dev/null +++ b/ext/mixed/js/media-loader.js @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2020 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 + * apiGetMedia + */ + +class MediaLoader { + constructor() { + this._token = {}; + this._mediaCache = new Map(); + this._loadMediaData = []; + } + + async loadMedia(path, dictionaryName, onLoad, onUnload) { + const token = this.token; + const data = {onUnload, loaded: false}; + + this._loadMediaData.push(data); + + const media = await this.getMedia(path, dictionaryName); + if (token !== this.token) { return; } + + onLoad(media.url); + data.loaded = true; + } + + unloadAll() { + for (const {onUnload, loaded} of this._loadMediaData) { + if (typeof onUnload === 'function') { + onUnload(loaded); + } + } + this._loadMediaData = []; + + for (const map of this._mediaCache.values()) { + for (const {url} of map.values()) { + if (url !== null) { + URL.revokeObjectURL(url); + } + } + } + this._mediaCache.clear(); + + this._token = {}; + } + + async getMedia(path, dictionaryName) { + let cachedData; + let dictionaryCache = this._mediaCache.get(dictionaryName); + if (typeof dictionaryCache !== 'undefined') { + cachedData = dictionaryCache.get(path); + } else { + dictionaryCache = new Map(); + this._mediaCache.set(dictionaryName, dictionaryCache); + } + + if (typeof cachedData === 'undefined') { + cachedData = { + promise: null, + data: null, + url: null + }; + dictionaryCache.set(path, cachedData); + cachedData.promise = this._getMediaData(path, dictionaryName, cachedData); + } + + return cachedData.promise; + } + + async _getMediaData(path, dictionaryName, cachedData) { + const token = this._token; + const data = (await apiGetMedia([{path, dictionaryName}]))[0]; + if (token === this._token && data !== null) { + const contentArrayBuffer = this._base64ToArrayBuffer(data.content); + const blob = new Blob([contentArrayBuffer], {type: data.mediaType}); + const url = URL.createObjectURL(blob); + cachedData.data = data; + cachedData.url = url; + } + return cachedData; + } + + _base64ToArrayBuffer(content) { + const binaryContent = window.atob(content); + const length = binaryContent.length; + const array = new Uint8Array(length); + for (let i = 0; i < length; ++i) { + array[i] = binaryContent.charCodeAt(i); + } + return array.buffer; + } +} diff --git a/ext/mixed/js/object-property-accessor.js b/ext/mixed/js/object-property-accessor.js index 349037b3..07b8df61 100644 --- a/ext/mixed/js/object-property-accessor.js +++ b/ext/mixed/js/object-property-accessor.js @@ -16,15 +16,27 @@ */ /** - * Class used to get and set generic properties of an object by using path strings. + * Class used to get and mutate generic properties of an object by using path strings. */ class ObjectPropertyAccessor { - constructor(target, setter=null) { + /** + * Create a new accessor for a specific object. + * @param target The object which the getter and mutation methods are applied to. + * @returns A new ObjectPropertyAccessor instance. + */ + constructor(target) { this._target = target; - this._setter = (typeof setter === 'function' ? setter : null); } - getProperty(pathArray, pathLength) { + /** + * Gets the value at the specified path. + * @param pathArray The path to the property on the target object. + * @param pathLength How many parts of the pathArray to use. + * This parameter is optional and defaults to the length of pathArray. + * @returns The value found at the path. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + get(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) { @@ -37,24 +49,89 @@ class ObjectPropertyAccessor { return target; } - setProperty(pathArray, value) { - if (pathArray.length === 0) { - throw new Error('Invalid path'); + /** + * Sets the value at the specified path. + * @param pathArray The path to the property on the target object. + * @param value The value to assign to the property. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + set(pathArray, value) { + const ii = pathArray.length - 1; + if (ii < 0) { throw new Error('Invalid path'); } + + const target = this.get(pathArray, ii); + const key = pathArray[ii]; + if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) { + throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); } - const target = this.getProperty(pathArray, pathArray.length - 1); - const key = pathArray[pathArray.length - 1]; + target[key] = value; + } + + /** + * Deletes the property of the target object at the specified path. + * @param pathArray The path to the property on the target object. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + delete(pathArray) { + const ii = pathArray.length - 1; + if (ii < 0) { throw new Error('Invalid path'); } + + const target = this.get(pathArray, ii); + const key = pathArray[ii]; 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; + if (Array.isArray(target)) { + throw new Error('Invalid type'); + } + + delete target[key]; + } + + /** + * Swaps two properties of an object or array. + * @param pathArray1 The path to the first property on the target object. + * @param pathArray2 The path to the second property on the target object. + * @throws An error is thrown if pathArray1 or pathArray2 is not valid for the target object, + * or if the swap cannot be performed. + */ + swap(pathArray1, pathArray2) { + const ii1 = pathArray1.length - 1; + if (ii1 < 0) { throw new Error('Invalid path 1'); } + const target1 = this.get(pathArray1, ii1); + const key1 = pathArray1[ii1]; + if (!ObjectPropertyAccessor.isValidPropertyType(target1, key1)) { throw new Error(`Invalid path 1: ${ObjectPropertyAccessor.getPathString(pathArray1)}`); } + + const ii2 = pathArray2.length - 1; + if (ii2 < 0) { throw new Error('Invalid path 2'); } + const target2 = this.get(pathArray2, ii2); + const key2 = pathArray2[ii2]; + if (!ObjectPropertyAccessor.isValidPropertyType(target2, key2)) { throw new Error(`Invalid path 2: ${ObjectPropertyAccessor.getPathString(pathArray2)}`); } + + const value1 = target1[key1]; + const value2 = target2[key2]; + + target1[key1] = value2; + try { + target2[key2] = value1; + } catch (e) { + // Revert + try { + target1[key1] = value1; + } catch (e2) { + // NOP + } + throw e; } } + /** + * Converts a path string to a path array. + * @param pathArray The path array to convert. + * @returns A string representation of pathArray. + */ static getPathString(pathArray) { const regexShort = /^[a-zA-Z_][a-zA-Z0-9_]*$/; let pathString = ''; @@ -86,6 +163,12 @@ class ObjectPropertyAccessor { return pathString; } + /** + * Converts a path array to a path string. For the most part, the format of this string + * matches Javascript's notation for property access. + * @param pathString The path string to convert. + * @returns An array representation of pathString. + */ static getPathArray(pathString) { const pathArray = []; let state = 'empty'; @@ -201,6 +284,14 @@ class ObjectPropertyAccessor { return pathArray; } + /** + * Checks whether an object or array has the specified property. + * @param object The object to test. + * @param property The property to check for existence. + * This value should be a string if the object is a non-array object. + * For arrays, it should be an integer. + * @returns true if the property exists, otherwise false. + */ static hasProperty(object, property) { switch (typeof property) { case 'string': @@ -222,6 +313,14 @@ class ObjectPropertyAccessor { } } + /** + * Checks whether a property is valid for the given object + * @param object The object to test. + * @param property The property to check for existence. + * @returns true if the property is correct for the given object type, otherwise false. + * For arrays, this means that the property should be a positive integer. + * For non-array objects, the property should be a string. + */ static isValidPropertyType(object, property) { switch (typeof property) { case 'string': diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index 0cd12cd7..b8688b08 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -21,47 +21,172 @@ * docRangeFromPoint */ -class TextScanner { - constructor(node, ignoreElements, ignorePoints) { - this.node = node; - this.ignoreElements = ignoreElements; - this.ignorePoints = ignorePoints; - - this.ignoreNodes = null; - - this.scanTimerPromise = null; - this.causeCurrent = null; - this.textSourceCurrent = null; - this.pendingLookup = false; - this.options = null; - - this.enabled = false; - this.eventListeners = new EventListenerCollection(); - - this.primaryTouchIdentifier = null; - this.preventNextContextMenu = false; - this.preventNextMouseDown = false; - this.preventNextClick = false; - this.preventScroll = false; +class TextScanner extends EventDispatcher { + constructor({node, ignoreElements, ignorePoint, search}) { + super(); + this._node = node; + this._ignoreElements = ignoreElements; + this._ignorePoint = ignorePoint; + this._search = search; + + this._ignoreNodes = null; + + this._causeCurrent = null; + this._scanTimerPromise = null; + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + this._pendingLookup = false; + this._options = null; + + this._enabled = false; + this._eventListeners = new EventListenerCollection(); + + this._primaryTouchIdentifier = null; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + this._preventScroll = false; + + this._canClearSelection = true; } - onMouseOver(e) { - if (this.ignoreElements().includes(e.target)) { - this.scanTimerClear(); + get canClearSelection() { + return this._canClearSelection; + } + + set canClearSelection(value) { + this._canClearSelection = value; + } + + get ignoreNodes() { + return this._ignoreNodes; + } + + set ignoreNodes(value) { + this._ignoreNodes = value; + } + + get causeCurrent() { + return this._causeCurrent; + } + + setEnabled(enabled) { + this._eventListeners.removeAllEventListeners(); + this._enabled = enabled; + if (this._enabled) { + this._hookEvents(); + } else { + this.clearSelection(true); + } + } + + setOptions(options) { + this._options = options; + } + + async searchAt(x, y, cause) { + try { + this._scanTimerClear(); + + if (this._pendingLookup) { + return; + } + + if (typeof this._ignorePoint === 'function' && await this._ignorePoint(x, y)) { + return; + } + + const textSource = docRangeFromPoint(x, y, this._options.scanning.deepDomScan); + try { + if (this._textSourceCurrent !== null && this._textSourceCurrent.equals(textSource)) { + return; + } + + this._pendingLookup = true; + const result = await this._search(textSource, cause); + if (result !== null) { + this._causeCurrent = cause; + this.setCurrentTextSource(textSource); + } + this._pendingLookup = false; + } finally { + if (textSource !== null) { + textSource.cleanup(); + } + } + } catch (e) { + yomichan.logError(e); + } + } + + getTextSourceContent(textSource, length) { + const clonedTextSource = textSource.clone(); + + clonedTextSource.setEndOffset(length); + + if (this._ignoreNodes !== null && clonedTextSource.range) { + length = clonedTextSource.text().length; + while (clonedTextSource.range && length > 0) { + const nodes = TextSourceRange.getNodesInRange(clonedTextSource.range); + if (!TextSourceRange.anyNodeMatchesSelector(nodes, this._ignoreNodes)) { + break; + } + --length; + clonedTextSource.setEndOffset(length); + } + } + + return clonedTextSource.text(); + } + + clearSelection(passive) { + if (!this._canClearSelection) { return; } + if (this._textSourceCurrent !== null) { + if (this._textSourceCurrentSelected) { + this._textSourceCurrent.deselect(); + } + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + } + this.trigger('clearSelection', {passive}); + } + + getCurrentTextSource() { + return this._textSourceCurrent; + } + + setCurrentTextSource(textSource) { + this._textSourceCurrent = textSource; + if (this._options.scanning.selectText) { + this._textSourceCurrent.select(); + this._textSourceCurrentSelected = true; + } else { + this._textSourceCurrentSelected = false; + } + } + + // Private + + _onMouseOver(e) { + if (this._ignoreElements().includes(e.target)) { + this._scanTimerClear(); } } - onMouseMove(e) { - this.scanTimerClear(); + _onMouseMove(e) { + this._scanTimerClear(); - if (this.pendingLookup || DOM.isMouseButtonDown(e, 'primary')) { + if (this._pendingLookup || DOM.isMouseButtonDown(e, 'primary')) { return; } - const scanningOptions = this.options.scanning; + const modifiers = DOM.getActiveModifiers(e); + this.trigger('activeModifiersChanged', {modifiers}); + + const scanningOptions = this._options.scanning; const scanningModifier = scanningOptions.modifier; if (!( - TextScanner.isScanningModifierPressed(scanningModifier, e) || + this._isScanningModifierPressed(scanningModifier, e) || (scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary')) )) { return; @@ -69,7 +194,7 @@ class TextScanner { const search = async () => { if (scanningModifier === 'none') { - if (!await this.scanTimerWait()) { + if (!await this._scanTimerWait()) { // Aborted return; } @@ -81,112 +206,110 @@ class TextScanner { search(); } - onMouseDown(e) { - if (this.preventNextMouseDown) { - this.preventNextMouseDown = false; - this.preventNextClick = true; + _onMouseDown(e) { + if (this._preventNextMouseDown) { + this._preventNextMouseDown = false; + this._preventNextClick = true; e.preventDefault(); e.stopPropagation(); return false; } if (DOM.isMouseButtonDown(e, 'primary')) { - this.scanTimerClear(); - this.onSearchClear(true); + this._scanTimerClear(); + this.clearSelection(false); } } - onMouseOut() { - this.scanTimerClear(); + _onMouseOut() { + this._scanTimerClear(); } - onClick(e) { - if (this.preventNextClick) { - this.preventNextClick = false; + _onClick(e) { + if (this._preventNextClick) { + this._preventNextClick = false; e.preventDefault(); e.stopPropagation(); return false; } } - onAuxClick() { - this.preventNextContextMenu = false; + _onAuxClick() { + this._preventNextContextMenu = false; } - onContextMenu(e) { - if (this.preventNextContextMenu) { - 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) { + _onTouchStart(e) { + if (this._primaryTouchIdentifier !== null || e.changedTouches.length === 0) { return; } - this.preventScroll = false; - this.preventNextContextMenu = false; - this.preventNextMouseDown = false; - this.preventNextClick = false; + this._preventScroll = false; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; const primaryTouch = e.changedTouches[0]; if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, window.getSelection())) { return; } - this.primaryTouchIdentifier = primaryTouch.identifier; + this._primaryTouchIdentifier = primaryTouch.identifier; - if (this.pendingLookup) { + if (this._pendingLookup) { return; } - const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null; + const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null; this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart') .then(() => { if ( - this.textSourceCurrent === null || - this.textSourceCurrent.equals(textSourceCurrentPrevious) + this._textSourceCurrent === null || + this._textSourceCurrent.equals(textSourceCurrentPrevious) ) { return; } - this.preventScroll = true; - this.preventNextContextMenu = true; - this.preventNextMouseDown = true; + this._preventScroll = true; + this._preventNextContextMenu = true; + this._preventNextMouseDown = true; }); } - onTouchEnd(e) { + _onTouchEnd(e) { if ( - this.primaryTouchIdentifier === null || - TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier) === null + this._primaryTouchIdentifier === null || + this._getTouch(e.changedTouches, this._primaryTouchIdentifier) === null ) { return; } - 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. - // this.preventNextContextMenu = false; - // this.preventNextMouseDown = false; + 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); + _onTouchCancel(e) { + this._onTouchEnd(e); } - onTouchMove(e) { - if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) { + _onTouchMove(e) { + if (!this._preventScroll || !e.cancelable || this._primaryTouchIdentifier === null) { return; } - const primaryTouch = TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier); + const primaryTouch = this._getTouch(e.changedTouches, this._primaryTouchIdentifier); if (primaryTouch === null) { return; } @@ -196,171 +319,70 @@ class TextScanner { e.preventDefault(); // Disable scroll } - async onSearchSource(_textSource, _cause) { - throw new Error('Override me'); - } - - onError(error) { - logError(error, false); - } - - async scanTimerWait() { - const delay = this.options.scanning.delay; + async _scanTimerWait() { + const delay = this._options.scanning.delay; const promise = promiseTimeout(delay, true); - this.scanTimerPromise = promise; + this._scanTimerPromise = promise; try { return await promise; } finally { - if (this.scanTimerPromise === promise) { - this.scanTimerPromise = null; + if (this._scanTimerPromise === promise) { + this._scanTimerPromise = null; } } } - scanTimerClear() { - if (this.scanTimerPromise !== null) { - this.scanTimerPromise.resolve(false); - this.scanTimerPromise = null; + _scanTimerClear() { + if (this._scanTimerPromise !== null) { + this._scanTimerPromise.resolve(false); + this._scanTimerPromise = null; } } - setEnabled(enabled, canEnable) { - if (enabled && canEnable) { - if (!this.enabled) { - this.hookEvents(); - this.enabled = true; - } - } else { - if (this.enabled) { - this.eventListeners.removeAllEventListeners(); - this.enabled = false; - } - this.onSearchClear(false); - } - } - - hookEvents() { - let eventListenerInfos = this.getMouseEventListeners(); - if (this.options.scanning.touchInputEnabled) { - eventListenerInfos = eventListenerInfos.concat(this.getTouchEventListeners()); + _hookEvents() { + const eventListenerInfos = this._getMouseEventListeners(); + if (this._options.scanning.touchInputEnabled) { + eventListenerInfos.push(...this._getTouchEventListeners()); } for (const [node, type, listener, options] of eventListenerInfos) { - this.eventListeners.addEventListener(node, type, listener, options); + this._eventListeners.addEventListener(node, type, listener, options); } } - getMouseEventListeners() { + _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, '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)] ]; } - getTouchEventListeners() { + _getTouchEventListeners() { return [ - [this.node, 'click', this.onClick.bind(this)], - [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)] + [this._node, 'click', this._onClick.bind(this)], + [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)] ]; } - setOptions(options, canEnable=true) { - this.options = options; - this.setEnabled(this.options.general.enable, canEnable); - } - - async searchAt(x, y, cause) { - try { - this.scanTimerClear(); - - if (this.pendingLookup) { - return; - } - - for (const ignorePointFn of this.ignorePoints) { - if (await ignorePointFn(x, y)) { - return; - } - } - - const textSource = docRangeFromPoint(x, y, this.options.scanning.deepDomScan); - try { - if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) { - return; - } - - 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(); - } - } - this.pendingLookup = false; - } finally { - if (textSource !== null) { - textSource.cleanup(); - } - } - } catch (e) { - this.onError(e); - } - } - - setTextSourceScanLength(textSource, length) { - textSource.setEndOffset(length); - if (this.ignoreNodes === null || !textSource.range) { - return; - } - - length = textSource.text().length; - while (textSource.range && length > 0) { - const nodes = TextSourceRange.getNodesInRange(textSource.range); - if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) { - break; - } - --length; - textSource.setEndOffset(length); - } - } - - onSearchClear(_) { - if (this.textSourceCurrent !== null) { - if (this.options.scanning.selectText) { - this.textSourceCurrent.deselect(); - } - this.textSourceCurrent = null; - } - } - - getCurrentTextSource() { - return this.textSourceCurrent; - } - - setCurrentTextSource(textSource) { - return this.textSourceCurrent = textSource; - } - - static isScanningModifierPressed(scanningModifier, mouseEvent) { + _isScanningModifierPressed(scanningModifier, mouseEvent) { switch (scanningModifier) { case 'alt': return mouseEvent.altKey; case 'ctrl': return mouseEvent.ctrlKey; case 'shift': return mouseEvent.shiftKey; + case 'meta': return mouseEvent.metaKey; case 'none': return true; default: return false; } } - static getTouch(touchList, identifier) { + _getTouch(touchList, identifier) { for (const touch of touchList) { if (touch.identifier === identifier) { return touch; diff --git a/package.json b/package.json index 0729cda1..1aa6c856 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "test": "npm run test-lint && npm run test-code", "test-lint": "eslint . && node ./test/lint/global-declarations.js", - "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js" + "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js && node ./test/test-dom-text-scanner.js" }, "repository": { "type": "git", diff --git a/resources/icons.svg b/resources/icons.svg index 4bc46c02..f096947b 100644 --- a/resources/icons.svg +++ b/resources/icons.svg @@ -15,10 +15,11 @@ viewBox="0 0 16 16" version="1.1" id="svg8" - inkscape:version="0.92.3 (2405546, 2018-03-11)" + inkscape:version="0.92.4 (5da689c313, 2019-01-14)" sodipodi:docname="icons.svg" - inkscape:export-xdpi="96" - inkscape:export-ydpi="96"> + inkscape:export-xdpi="192" + inkscape:export-ydpi="192" + inkscape:export-filename="../ext/mixed/img/icon32.png"> <sodipodi:namedview id="base" pagecolor="#ffffff" @@ -27,15 +28,15 @@ inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="31.678384" - inkscape:cx="4.6828273" - inkscape:cy="7.5629224" + inkscape:cx="6.7837201" + inkscape:cy="7.0569373" inkscape:document-units="px" - inkscape:current-layer="layer16" + inkscape:current-layer="layer24" showgrid="true" units="px" inkscape:snap-center="true" inkscape:window-width="1920" - inkscape:window-height="1018" + inkscape:window-height="1017" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:window-maximized="1" @@ -498,12 +499,12 @@ <dc:format>image/svg+xml</dc:format> <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title /> + <dc:title></dc:title> </cc:Work> </rdf:RDF> </metadata> <g - style="display:inline" + style="display:none" inkscape:label="Add Term Kanji" id="layer5" inkscape:groupmode="layer"> @@ -520,7 +521,7 @@ style="image-rendering:optimizeSpeed" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0 U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAJvSURBVDjLpZPrS5NhGIf9W7YvBYOkhlko qCklWChv2WyKik7blnNris72bi6dus0DLZ0TDxW1odtopDs4D8MDZuLU0kXq61CijSIIasOvv94V TUfLiB74fXngup7nvrnvJABJ/5PfLnTTdcwOj4RsdYmo5glBWP6iOtzwvIKSWstI0Wgx80SBblpK tE9KQs/We7EaWoT/8wbWP61gMmCH0lMDvokT4j25TiQU/ITFkek9Ow6+7WH2gwsmahCPdwyw75uw 9HEO2gUZSkfyI9zBPCJOoJ2SMmg46N61YO/rNoa39Xi41oFuXysMfh36/Fp0b7bAfWAH6RGi0Hgl WNCbzYgJaFjRv6zGuy+b9It96N3SQvNKiV9HvSaDfFEIxXItnPs23BzJQd6DDEVM0OKsoVwBG/1V MzpXVWhbkUM2K4oJBDYuGmbKIJ0qxsAbHfRLzbjcnUbFBIpx/qH3vQv9b3U03IQ/HfFkERTzfFj8 w8jSpR7GBE123uFEYAzaDRIqX/2JAtJbDat/COkd7CNBva2cMvq0MGxp0PRSCPF8BXjWG3FgNHc9 XPT71Ojy3sMFdfJRCeKxEsVtKwFHwALZfCUk3tIfNR8XiJwc1LmL4dg141JPKtj3WUdNFJqLGFVP C4OkR4BxajTWsChY64wmCnMxsWPCHcutKBxMVp5mxA1S+aMComToaqTRUQknLTH62kHOVEE+VQnj ahscNCy0cMBWsSI0TCQcZc5ALkEYckL5A5noWSBhfm2AecMAjbcRWV0pUTh0HE64TNf0mczcnnQy u/MilaFJCae1nw2fbz1DnVOxyGTlKeZft/Ff8x1BRssfACjTwQAAAABJRU5ErkJggg== " id="image4539" - x="4.7683716e-007" + x="4.7683716e-07" y="292.76669" /> </g> <g @@ -889,4 +890,32 @@ inkscape:connector-curvature="0" /> </g> </g> + <g + inkscape:groupmode="layer" + id="layer22" + inkscape:label="Yomichan"> + <g + inkscape:groupmode="layer" + id="layer23" + inkscape:label="Background" + style="display:inline"> + <rect + id="rect3857" + width="16" + height="16" + x="0" + y="0" + rx="1.625" + ry="1.625" /> + </g> + <g + inkscape:groupmode="layer" + id="layer24" + inkscape:label="Characters"> + <path + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="M 2 2 L 2 4 L 5 4 L 5 7 L 2 7 L 2 9 L 5 9 L 5 12 L 2 12 L 2 14 L 7 14 L 7 2 L 2 2 z M 9 2 L 9 4 L 14 4 L 14 2 L 9 2 z M 9 7 L 9 9 L 14 9 L 14 7 L 9 7 z M 9 12 L 9 14 L 14 14 L 14 12 L 9 12 z " + id="path3859" /> + </g> + </g> </svg> diff --git a/test/data/dictionaries/valid-dictionary1/image.gif b/test/data/dictionaries/valid-dictionary1/image.gif Binary files differnew file mode 100644 index 00000000..f089d07c --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/image.gif diff --git a/test/data/dictionaries/valid-dictionary1/term_bank_1.json b/test/data/dictionaries/valid-dictionary1/term_bank_1.json index 755d9f6a..a62ad117 100644 --- a/test/data/dictionaries/valid-dictionary1/term_bank_1.json +++ b/test/data/dictionaries/valid-dictionary1/term_bank_1.json @@ -30,5 +30,6 @@ ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 29, ["definition1a (打ち込む, ぶちこむ)", "definition1b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"], ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 30, ["definition2a (打ち込む, ぶちこむ)", "definition2b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"], ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 31, ["definition3a (打ち込む, ぶちこむ)", "definition3b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"], - ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 32, ["definition4a (打ち込む, ぶちこむ)", "definition4b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"] + ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 32, ["definition4a (打ち込む, ぶちこむ)", "definition4b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"], + ["画像", "がぞう", "tag1 tag2", "", 33, ["definition1a (画像, がぞう)", {"type": "image", "path": "image.gif", "width": 350, "height": 350, "description": "An image", "pixelated": true}], 7, "tag3 tag4 tag5"] ]
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json index 26922394..73d74e68 100644 --- a/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json +++ b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json @@ -2,6 +2,12 @@ ["打", "freq", 1], ["打つ", "freq", 2], ["打ち込む", "freq", 3], + ["打", "freq", {"reading": "だ", "frequency": 4}], + ["打", "freq", {"reading": "ダース", "frequency": 5}], + ["打つ", "freq", {"reading": "うつ", "frequency": 6}], + ["打つ", "freq", {"reading": "ぶつ", "frequency": 7}], + ["打ち込む", "freq", {"reading": "うちこむ", "frequency": 8}], + ["打ち込む", "freq", {"reading": "ぶちこむ", "frequency": 9}], [ "打ち込む", "pitch", diff --git a/test/data/html/test-document1.html b/test/data/html/test-document1.html index 0754a314..98a6fb44 100644 --- a/test/data/html/test-document1.html +++ b/test/data/html/test-document1.html @@ -103,6 +103,7 @@ data-end-node-selector="img" data-end-offset="0" data-result-type="TextSourceElement" + data-sentence-extent="100" data-sentence="よみちゃん" > <img src="data:image/gif;base64,R0lGODdhBwAHAIABAAAAAP///ywAAAAABwAHAAACDIRvEaC32FpCbEkKCgA7" alt="よみちゃん" title="よみちゃん" style="width: 70px; height: 70px; image-rendering: crisp-edges; image-rendering: pixelated; display: block;" /> diff --git a/test/data/html/test-document2-frame1.html b/test/data/html/test-document2-frame1.html index 3b85cdc5..e572e3c4 100644 --- a/test/data/html/test-document2-frame1.html +++ b/test/data/html/test-document2-frame1.html @@ -33,10 +33,8 @@ a, a:visited { ありがとう </div> <div> - <a href="#" id="fullscreen-link">Toggle fullscreen</a> - <script> -document.querySelector('#fullscreen-link').addEventListener('click', () => toggleFullscreen(document.body), false); - </script> + <a href="#" class="fullscreen-link">Toggle fullscreen</a> + <script>setup(document.body, document.body);</script> </div> </div></body> </html>
\ No newline at end of file diff --git a/test/data/html/test-document2-script.js b/test/data/html/test-document2-script.js index bd5a570d..ab516a4e 100644 --- a/test/data/html/test-document2-script.js +++ b/test/data/html/test-document2-script.js @@ -39,3 +39,27 @@ function toggleFullscreen(element) { requestFullscreen(element); } } + +function setup(container, fullscreenElement=null) { + const fullscreenLink = container.querySelector('.fullscreen-link'); + if (fullscreenLink !== null) { + if (fullscreenElement === null) { + fullscreenElement = container.querySelector('.fullscreen-element'); + } + fullscreenLink.addEventListener('click', (e) => { + toggleFullscreen(fullscreenElement); + e.preventDefault(); + return false; + }, false); + } + + const template = container.querySelector('template'); + const templateContentContainer = container.querySelector('.template-content-container'); + if (template !== null && templateContentContainer !== null) { + const mode = container.dataset.shadowMode; + const shadow = templateContentContainer.attachShadow({mode}); + const content = document.importNode(template.content, true); + setup(content); + shadow.appendChild(content); + } +} diff --git a/test/data/html/test-document2.html b/test/data/html/test-document2.html index 3a22a5bf..6d174571 100644 --- a/test/data/html/test-document2.html +++ b/test/data/html/test-document2.html @@ -11,71 +11,78 @@ <body> <h1>Yomichan Manual Tests</h1> - <p class="description">Manual tests involving fullscreen elements, <iframe>s, and shadow DOMs.</p> + <y-description>Manual tests involving fullscreen elements, <iframe>s, and shadow DOMs.</y-description> - <div class="test"> - <div class="description">Standard content.</div> - <div id="fullscreen-element1" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;"> + <y-test> + <y-description>Standard content.</y-description> + <div class="fullscreen-element" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;"> <div> ありがとう </div> <div> - <a href="#" id="fullscreen-link1">Toggle fullscreen</a> + <a href="#" class="fullscreen-link">Toggle fullscreen</a> </div> </div></div> - <script> -document.querySelector('#fullscreen-link1').addEventListener('click', () => toggleFullscreen(document.querySelector('#fullscreen-element1')), false); - </script> - </div> + </y-test> - <div class="test"> - <div class="description">Content inside of a shadow DOM.</div> - <div id="shadow-content-container"></div> - <template id="shadow-content-container-content-template"> + <y-test data-shadow-mode="open"> + <y-description>Content inside of an open shadow DOM.</y-description> + <div class="template-content-container"></div> + <template> <link rel="stylesheet" href="test-stylesheet.css" /> - <div id="fullscreen-element2" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;"> + <div class="fullscreen-element" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;"> <div> ありがとう </div> <div> - <a href="#" id="fullscreen-link2">Toggle fullscreen</a> + <a href="#" class="fullscreen-link">Toggle fullscreen</a> </div> </div></div> </template> - <script> -(() => { - const shadowIframeContainer = document.querySelector('#shadow-content-container'); - const shadow = shadowIframeContainer.attachShadow({mode: 'closed'}); - const template = document.querySelector('#shadow-content-container-content-template').content; - const content = document.importNode(template, true); - const fullscreenElement = content.querySelector('#fullscreen-element2'); - content.querySelector('#fullscreen-link2').addEventListener('click', () => toggleFullscreen(fullscreenElement), false); - shadow.appendChild(content); -})(); - </script> - </div> + </y-test> - <div class="test"> - <div class="description"><iframe> element.</div> + <y-test data-shadow-mode="closed"> + <y-description>Content inside of a closed shadow DOM.</y-description> + <div class="template-content-container"></div> + <template> + <link rel="stylesheet" href="test-stylesheet.css" /> + <div class="fullscreen-element" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;"> + <div> + ありがとう + </div> + <div> + <a href="#" class="fullscreen-link">Toggle fullscreen</a> + </div> + </div></div> + </template> + </y-test> + + <y-test> + <y-description><iframe> element.</y-description> <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe> - </div> + </y-test> + + <y-test data-shadow-mode="open"> + <y-description><iframe> element inside of an open shadow DOM.</y-description> + <div class="template-content-container"></div> + <template> + <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe> + </template> + </y-test> - <div class="test"> - <div class="description"><iframe> element inside of a shadow DOM.</div> - <div id="shadow-iframe-container"></div> - <template id="shadow-iframe-container-content-template"> + <y-test data-shadow-mode="closed"> + <y-description><iframe> element inside of a closed shadow DOM.</y-description> + <div class="template-content-container"></div> + <template> <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe> </template> - <script> -(() => { - const shadowIframeContainer = document.querySelector('#shadow-iframe-container'); - const shadow = shadowIframeContainer.attachShadow({mode: 'closed'}); - const template = document.querySelector('#shadow-iframe-container-content-template').content; - const content = document.importNode(template, true); - shadow.appendChild(content); -})(); - </script> - </div> + </y-test> + + <script> +for (const element of document.querySelectorAll('y-test')) { + setup(element); +} + </script> </body> -</html>
\ No newline at end of file +</html> diff --git a/test/data/html/test-document3-frame1.html b/test/data/html/test-document3-frame1.html new file mode 100644 index 00000000..2ae906d2 --- /dev/null +++ b/test/data/html/test-document3-frame1.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Yomichan Manual Performance Tests</title> + <link rel="stylesheet" href="test-stylesheet.css" /> + </head> +<body><div class="content"> + + <div class="description">Add elements</div> + + <div> + <a href="#" id="add-elements-1000">1000</a> + <a href="#" id="add-elements-10000">10000</a> + <a href="#" id="add-elements-100000">100000</a> + <a href="#" id="add-elements-1000000">1000000</a> + <script> +document.querySelector('#add-elements-1000').addEventListener('click', () => addElements(1000), false); +document.querySelector('#add-elements-10000').addEventListener('click', () => addElements(10000), false); +document.querySelector('#add-elements-100000').addEventListener('click', () => addElements(100000), false); +document.querySelector('#add-elements-1000000').addEventListener('click', () => addElements(1000000), false); + +let counter = 0; + +function addElements(amount) { + const container = document.querySelector('#container'); + for (let i = 0; i < amount; i++) { + const element = document.createElement('div'); + element.textContent = 'ありがとう'; + container.appendChild(element); + } + + counter += amount; + document.querySelector('#counter').textContent = counter; +} + </script> + </div> + + <div id="counter"></div> + <div id="container"></div> + +</div></body> +</html> diff --git a/test/data/html/test-document3-frame2.html b/test/data/html/test-document3-frame2.html new file mode 100644 index 00000000..c486e04b --- /dev/null +++ b/test/data/html/test-document3-frame2.html @@ -0,0 +1,62 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Yomichan Manual Performance Tests</title> + <link rel="stylesheet" href="test-stylesheet.css" /> + </head> +<body><div class="content"> + + <div class="description"><iframe> element inside of an open shadow DOM.</div> + + <div id="shadow-iframe-container-open"></div> + <template id="shadow-iframe-container-open-content-template"> + <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 50px; border: 1px solid #d8d8d8;"></iframe> + </template> + <script> +(() => { + const shadowIframeContainer = document.querySelector('#shadow-iframe-container-open'); + const shadow = shadowIframeContainer.attachShadow({mode: 'open'}); + const template = document.querySelector('#shadow-iframe-container-open-content-template').content; + const content = document.importNode(template, true); + shadow.appendChild(content); +})(); + </script> + + <div class="description">Add elements</div> + + <div> + <a href="#" id="add-elements-1000">1000</a> + <a href="#" id="add-elements-10000">10000</a> + <a href="#" id="add-elements-100000">100000</a> + <a href="#" id="add-elements-1000000">1000000</a> + </div> + + <div id="counter"></div> + <div id="container"></div> + <script> +(() => { + document.querySelector('#add-elements-1000').addEventListener('click', () => addElements(1000), false); + document.querySelector('#add-elements-10000').addEventListener('click', () => addElements(10000), false); + document.querySelector('#add-elements-100000').addEventListener('click', () => addElements(100000), false); + document.querySelector('#add-elements-1000000').addEventListener('click', () => addElements(1000000), false); + + let counter = 0; + + function addElements(amount) { + const container = document.querySelector('#container'); + for (let i = 0; i < amount; i++) { + const element = document.createElement('div'); + element.textContent = 'ありがとう'; + container.appendChild(element); + } + + counter += amount; + document.querySelector('#counter').textContent = counter; + } +})(); + </script> + +</div></body> +</html> diff --git a/test/data/html/test-document3.html b/test/data/html/test-document3.html new file mode 100644 index 00000000..3e7d5236 --- /dev/null +++ b/test/data/html/test-document3.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Yomichan Manual Performance Tests</title> + <link rel="icon" type="image/gif" href="data:image/gif;base64,R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAImFI6Zpt0B4YkS0TCpq07xbmEgcGVRUpLaI46ZG7ppalY0jDCwUAAAOw==" /> + <link rel="stylesheet" href="test-stylesheet.css" /> + </head> +<body> + + <h1>Yomichan Manual Performance Tests</h1> + <p class="description">Testing Yomichan performance with artificially demanding cases in a real browser</p> + + <div class="test"> + <div class="description"><iframe> element.</div> + <iframe src="test-document3-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe> + </div> + + <div class="test"> + <div class="description"><iframe> element containing an <iframe> element inside of an open shadow DOM.</div> + <iframe src="test-document3-frame2.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe> + </div> + +</body> +</html> diff --git a/test/data/html/test-dom-text-scanner.html b/test/data/html/test-dom-text-scanner.html new file mode 100644 index 00000000..dc06eb64 --- /dev/null +++ b/test/data/html/test-dom-text-scanner.html @@ -0,0 +1,393 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Yomichan DOMTextScanner Tests</title> + <link rel="icon" type="image/gif" href="data:image/gif;base64,R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAImFI6Zpt0B4YkS0TCpq07xbmEgcGVRUpLaI46ZG7ppalY0jDCwUAAAOw==" /> + <link rel="stylesheet" href="test-stylesheet.css" /> + </head> +<body> + + <h1>Yomichan DOMTextScanner Tests</h1> + + <y-test + data-test-data='{ + "node": "div:nth-of-type(1)", + "offset": 0, + "length": 15, + "expected": { + "node": "div:nth-of-type(2)>div::text", + "offset": 3, + "content": "小ぢん\nまり1\n小ぢん\nまり2" + } + }' + > + <y-description>Layout newlines expected due to entering and exiting display:block nodes.</y-description> +<div><div>小ぢん</div>まり1</div> +<div>小ぢん<div>まり2</div></div> + </y-test> + + <y-test + data-test-data='{ + "node": "div:nth-of-type(1)::text", + "offset": 0, + "length": 13, + "expected": { + "node": "div:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1\n小ぢんまり2" + } + }' + > + <y-description>Layout newline expected due to sequential display:block elements.</y-description> +<div>小ぢんまり1</div><div>小ぢんまり2</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div:nth-of-type(1)::text", + "offset": 0, + "length": 13, + "expected": { + "node": "div:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1\n小ぢんまり2" + } + }' + > + <y-description>Layout newline expected due to sequential display:block elements separated by a newline.</y-description> +<div>小ぢんまり1</div> +<div>小ぢんまり2</div> + </y-test> + + <y-test + data-test-data='{ + "node": "span:nth-of-type(1)::text", + "offset": 0, + "length": 12, + "expected": { + "node": "span:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1小ぢんまり2" + } + }' + > + <y-description>No newlines expected due to display:inline.</y-description> +<span>小ぢんまり1</span><span>小ぢんまり2</span> + </y-test> + + <y-test + data-test-data='{ + "node": "span:nth-of-type(1)::text", + "offset": 0, + "length": 13, + "expected": { + "node": "span:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1 小ぢんまり2" + } + }' + > + <y-description>No newlines expected due to white-space:normal.</y-description> +<span>小ぢんまり1</span> +<span>小ぢんまり2</span> + </y-test> + + <y-test + data-test-data='{ + "node": "span:nth-of-type(1)::text", + "offset": 0, + "length": 13, + "expected": { + "node": "span:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1\n小ぢんまり2" + } + }' + > + <y-description>Newline expected due to white-space:pre.</y-description> +<pre> +<span>小ぢんまり1</span> +<span>小ぢんまり2</span> +</pre> + </y-test> + + <y-test + data-test-data='{ + "node": "span:nth-of-type(1)::text", + "offset": 0, + "length": 12, + "expected": { + "node": "span:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1小ぢんまり2" + } + }' + > + <y-description>No newlines expected due to display:inline-block. Actual layout flow cannot be determined by DOM/CSS alone.</y-description> +<span style="display: inline-block;">小ぢんまり1</span><span style="display: inline-block;">小ぢんまり2</span> + </y-test> + + <y-test + style="position: relative;" + data-test-data='{ + "node": "div:nth-of-type(1)::text", + "offset": 0, + "length": 13, + "expected": { + "node": "div:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1\n小ぢんまり2" + } + }' + > + <y-description>Single newline expected due to display:block layout.</y-description> +<div>小ぢんまり1</div><div style="position: relative;">小ぢんまり2</div> + </y-test> + + <y-test + style="position: relative; overflow: hidden;" + data-test-data='{ + "node": "div:nth-of-type(1)::text", + "offset": 0, + "length": 14, + "expected": { + "node": "div:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1\n\n小ぢんまり2" + } + }' + > + <y-description>Two newlines expected due to position:absolute causing a significant layout change.</y-description> +<div>小ぢんまり1</div><div style="position: absolute;">小ぢんまり2</div> + </y-test> + + <y-test + style="position: relative; overflow: hidden;" + data-test-data='{ + "node": "div:nth-of-type(1)::text", + "offset": 0, + "length": 14, + "expected": { + "node": "div:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1\n\n小ぢんまり2" + } + }' + > + <y-description>Two newlines expected due to position:fixed causing a significant layout change.</y-description> +<div>小ぢんまり1</div><div style="position: fixed;">小ぢんまり2</div> + </y-test> + + <y-test + style="position: relative;" + data-test-data='{ + "node": "div:nth-of-type(1)::text", + "offset": 0, + "length": 14, + "expected": { + "node": "div:nth-of-type(2)::text", + "offset": 6, + "content": "小ぢんまり1\n\n小ぢんまり2" + } + }' + > + <y-description>Two newlines expected due to position:sticky being able to cause a significant layout change.</y-description> +<div>小ぢんまり1</div><div style="position: sticky;">小ぢんまり2</div> + </y-test> + + <y-test + data-test-data='{ + "node": "rt", + "offset": 0, + "length": 6, + "expected": { + "node": "div::text", + "offset": 5, + "content": "小ぢんまり1" + } + }' + > + <y-description>Scanning text starting in an <rt> element. Should start scanning at the start of the <ruby> tag instead.</y-description> +<div><ruby>小<rp>(</rp><rt>こ</rt><rp>)</rp></ruby>ぢんまり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip <script> content.</y-description> +<div>小ぢん<script>/*comment*/</script>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip <style> content.</y-description> +<div>小ぢん<style>/*comment*/</style>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip <textarea> content.</y-description> +<div>小ぢん<textarea>textarea content</textarea>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip <input> content.</y-description> +<div>小ぢん<input value="content" />まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip <button> content.</y-description> +<div>小ぢん<button>content</button>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip content with font-size:0.</y-description> +<div>小ぢん<span style="font-size: 0;">content</span>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip content with opacity:0.</y-description> +<div>小ぢん<span style="opacity: 0;">content</span>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip content with visibility:hidden.</y-description> +<div>小ぢん<span style="visibility: hidden;">content</span>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip content with display:none.</y-description> +<div>小ぢん<span style="display: none;">content</span>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Don't skip content with user-select:none.</y-description> +<div>小ぢ<span style="user-select: none;">ん</span>まり1</div> + </y-test> + + <y-test + data-test-data='{ + "node": "div", + "offset": 0, + "length": 6, + "expected": { + "node": "div::nth-text(2)", + "offset": 3, + "content": "小ぢんまり1" + } + }' + > + <y-description>Skip content with user-select:none <em>and</em> a transparent color.</y-description> +<div>小ぢん<span style="user-select: none; color: rgba(0, 0, 0, 0);">content</span>まり1</div> + </y-test> + +</body> +</html>
\ No newline at end of file diff --git a/test/data/html/test-stylesheet.css b/test/data/html/test-stylesheet.css index f63d2481..b4d2e255 100644 --- a/test/data/html/test-stylesheet.css +++ b/test/data/html/test-stylesheet.css @@ -19,7 +19,8 @@ p { margin: 0.33em 0; } -h1+p { +h1+p, +h1+y-description { margin-top: -0.67em; } @@ -28,7 +29,9 @@ a, a:visited { text-decoration: underline; } -.test { +.test, +y-test { + display: block; background-color: #ffffff; margin: 1em 0; padding: 0.5em; @@ -36,7 +39,8 @@ a, a:visited { border-radius: 4px; } -.test:before { +.test:before, +y-test:before { content: "Test " counter(test-id); display: block; counter-increment: test-id; @@ -45,7 +49,10 @@ a, a:visited { font-weight: bold; } -.description { +.description, +y-description { color: #444444; font-style: italic; + display: block; + padding-bottom: 0.5em; } diff --git a/test/test-database.js b/test/test-database.js index d27f92e1..e8a4a343 100644 --- a/test/test-database.js +++ b/test/test-database.js @@ -92,13 +92,63 @@ class XMLHttpRequest { } } +class Image { + constructor() { + this._src = ''; + this._loadCallbacks = []; + } + + get src() { + return this._src; + } + + set src(value) { + this._src = value; + this._delayTriggerLoad(); + } + + get naturalWidth() { + return 100; + } + + get naturalHeight() { + return 100; + } + + addEventListener(eventName, callback) { + if (eventName === 'load') { + this._loadCallbacks.push(callback); + } + } + + removeEventListener(eventName, callback) { + if (eventName === 'load') { + const index = this._loadCallbacks.indexOf(callback); + if (index >= 0) { + this._loadCallbacks.splice(index, 1); + } + } + } + + async _delayTriggerLoad() { + await Promise.resolve(); + for (const callback of this._loadCallbacks) { + callback(); + } + } +} + const vm = new VM({ chrome, + Image, XMLHttpRequest, indexedDB: global.indexedDB, IDBKeyRange: global.IDBKeyRange, - JSZip: yomichanTest.JSZip + JSZip: yomichanTest.JSZip, + addEventListener() { + // NOP + } }); vm.context.window = vm.context; @@ -106,6 +156,7 @@ vm.execute([ 'bg/js/json-schema.js', 'bg/js/dictionary.js', 'mixed/js/core.js', + 'bg/js/media-utility.js', 'bg/js/request.js', 'bg/js/dictionary-importer.js', 'bg/js/database.js' @@ -182,10 +233,10 @@ async function testDatabase1() { let progressEvent = false; await database.deleteDictionary( title, + {rate: 1000}, () => { progressEvent = true; - }, - {rate: 1000} + } ); assert.ok(progressEvent); @@ -216,10 +267,10 @@ async function testDatabase1() { const {result, errors} = await dictionaryImporter.import( database, testDictionarySource, + {prefixWildcardsSupported: true}, () => { progressEvent = true; - }, - {prefixWildcardsSupported: true} + } ); vm.assert.deepStrictEqual(errors, []); vm.assert.deepStrictEqual(result, expectedSummary); @@ -235,8 +286,8 @@ async function testDatabase1() { true ); vm.assert.deepStrictEqual(counts, { - counts: [{kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 6, tagMeta: 14}], - total: {kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 6, tagMeta: 14} + counts: [{kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14}], + total: {kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14} }); // Test find* functions @@ -626,9 +677,9 @@ async function testFindTermMetaBulk1(database, titles) { } ], expectedResults: { - total: 1, + total: 3, modes: [ - ['freq', 1] + ['freq', 3] ] } }, @@ -639,9 +690,9 @@ async function testFindTermMetaBulk1(database, titles) { } ], expectedResults: { - total: 1, + total: 3, modes: [ - ['freq', 1] + ['freq', 3] ] } }, @@ -652,9 +703,9 @@ async function testFindTermMetaBulk1(database, titles) { } ], expectedResults: { - total: 3, + total: 5, modes: [ - ['freq', 1], + ['freq', 3], ['pitch', 2] ] } @@ -857,7 +908,7 @@ async function testDatabase2() { // Error: not prepared await assert.rejects(async () => await database.purge()); - await assert.rejects(async () => await database.deleteDictionary(title, () => {}, {})); + await assert.rejects(async () => await database.deleteDictionary(title, {}, () => {})); await assert.rejects(async () => await database.findTermsBulk(['?'], titles, null)); await assert.rejects(async () => await database.findTermsExactBulk(['?'], ['?'], titles)); await assert.rejects(async () => await database.findTermsBySequenceBulk([1], title)); @@ -868,17 +919,17 @@ async function testDatabase2() { await assert.rejects(async () => await database.findTagForTitle('tag', title)); await assert.rejects(async () => await database.getDictionaryInfo()); await assert.rejects(async () => await database.getDictionaryCounts(titles, true)); - await assert.rejects(async () => await dictionaryImporter.import(database, testDictionarySource, () => {}, {})); + await assert.rejects(async () => await dictionaryImporter.import(database, testDictionarySource, {}, () => {})); await database.prepare(); // Error: already prepared await assert.rejects(async () => await database.prepare()); - await dictionaryImporter.import(database, testDictionarySource, () => {}, {}); + await dictionaryImporter.import(database, testDictionarySource, {}, () => {}); // Error: dictionary already imported - await assert.rejects(async () => await dictionaryImporter.import(database, testDictionarySource, () => {}, {})); + await assert.rejects(async () => await dictionaryImporter.import(database, testDictionarySource, {}, () => {})); await database.close(); } @@ -905,7 +956,7 @@ async function testDatabase3() { let error = null; try { - await dictionaryImporter.import(database, testDictionarySource, () => {}, {}); + await dictionaryImporter.import(database, testDictionarySource, {}, () => {}); } catch (e) { error = e; } diff --git a/test/test-dom-text-scanner.js b/test/test-dom-text-scanner.js new file mode 100644 index 00000000..7374ff87 --- /dev/null +++ b/test/test-dom-text-scanner.js @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2020 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 fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const {JSDOM} = require('jsdom'); +const {VM} = require('./yomichan-vm'); + + +function createJSDOM(fileName) { + const domSource = fs.readFileSync(fileName, {encoding: 'utf8'}); + return new JSDOM(domSource); +} + +function querySelectorTextNode(element, selector) { + let textIndex = -1; + const match = /::text$|::nth-text\((\d+)\)$/.exec(selector); + if (match !== null) { + textIndex = (match[1] ? parseInt(match[1], 10) - 1 : 0); + selector = selector.substring(0, selector.length - match[0].length); + } + const result = element.querySelector(selector); + if (textIndex < 0) { + return result; + } + for (let n = result.firstChild; n !== null; n = n.nextSibling) { + if (n.nodeType === n.constructor.TEXT_NODE) { + if (textIndex === 0) { + return n; + } + --textIndex; + } + } + return null; +} + + +function getComputedFontSizeInPixels(window, getComputedStyle, element) { + for (; element !== null; element = element.parentNode) { + if (element.nodeType === window.Node.ELEMENT_NODE) { + const fontSize = getComputedStyle(element).fontSize; + if (fontSize.endsWith('px')) { + const value = parseFloat(fontSize.substring(0, fontSize.length - 2)); + return value; + } + } + } + const defaultFontSize = 14; + return defaultFontSize; +} + +function createAbsoluteGetComputedStyle(window) { + // Wrapper to convert em units to px units + const getComputedStyleOld = window.getComputedStyle.bind(window); + return (element, ...args) => { + const style = getComputedStyleOld(element, ...args); + return new Proxy(style, { + get: (target, property) => { + let result = target[property]; + if (typeof result === 'string') { + result = result.replace(/([-+]?\d(?:\.\d)?(?:[eE][-+]?\d+)?)em/g, (g0, g1) => { + const fontSize = getComputedFontSizeInPixels(window, getComputedStyleOld, element); + return `${parseFloat(g1) * fontSize}px`; + }); + } + return result; + } + }); + }; +} + + +async function testDomTextScanner(dom, {DOMTextScanner}) { + const document = dom.window.document; + for (const testElement of document.querySelectorAll('y-test')) { + let testData = JSON.parse(testElement.dataset.testData); + if (!Array.isArray(testData)) { + testData = [testData]; + } + for (const testDataItem of testData) { + let { + node, + offset, + length, + forcePreserveWhitespace, + generateLayoutContent, + reversible, + expected: { + node: expectedNode, + offset: expectedOffset, + content: expectedContent, + remainder: expectedRemainder + } + } = testDataItem; + + node = querySelectorTextNode(testElement, node); + expectedNode = querySelectorTextNode(testElement, expectedNode); + + // Standard test + { + const scanner = new DOMTextScanner(node, offset, forcePreserveWhitespace, generateLayoutContent); + scanner.seek(length); + + const {node: actualNode1, offset: actualOffset1, content: actualContent1, remainder: actualRemainder1} = scanner; + assert.strictEqual(actualContent1, expectedContent); + assert.strictEqual(actualOffset1, expectedOffset); + assert.strictEqual(actualNode1, expectedNode); + assert.strictEqual(actualRemainder1, expectedRemainder || 0); + } + + // Substring tests + for (let i = 1; i <= length; ++i) { + const scanner = new DOMTextScanner(node, offset, forcePreserveWhitespace, generateLayoutContent); + scanner.seek(length - i); + + const {content: actualContent} = scanner; + assert.strictEqual(actualContent, expectedContent.substring(0, expectedContent.length - i)); + } + + if (reversible === false) { continue; } + + // Reversed test + { + const scanner = new DOMTextScanner(expectedNode, expectedOffset, forcePreserveWhitespace, generateLayoutContent); + scanner.seek(-length); + + const {content: actualContent} = scanner; + assert.strictEqual(actualContent, expectedContent); + } + + // Reversed substring tests + for (let i = 1; i <= length; ++i) { + const scanner = new DOMTextScanner(expectedNode, expectedOffset, forcePreserveWhitespace, generateLayoutContent); + scanner.seek(-(length - i)); + + const {content: actualContent} = scanner; + assert.strictEqual(actualContent, expectedContent.substring(i)); + } + } + } +} + + +async function testDocument1() { + const dom = createJSDOM(path.join(__dirname, 'data', 'html', 'test-dom-text-scanner.html')); + const window = dom.window; + try { + const {document, Node, Range} = window; + + window.getComputedStyle = createAbsoluteGetComputedStyle(window); + + const vm = new VM({document, window, Range, Node}); + vm.execute('fg/js/dom-text-scanner.js'); + const DOMTextScanner = vm.get('DOMTextScanner'); + + await testDomTextScanner(dom, {DOMTextScanner}); + } finally { + window.close(); + } +} + + +async function main() { + await testDocument1(); +} + + +if (require.main === module) { main(); } diff --git a/test/test-japanese.js b/test/test-japanese.js index 87efdfad..39004128 100644 --- a/test/test-japanese.js +++ b/test/test-japanese.js @@ -22,8 +22,7 @@ const vm = new VM(); vm.execute([ 'mixed/lib/wanakana.min.js', 'mixed/js/japanese.js', - 'bg/js/text-source-map.js', - 'bg/js/japanese.js' + 'bg/js/text-source-map.js' ]); const jp = vm.get('jp'); const TextSourceMap = vm.get('TextSourceMap'); @@ -434,17 +433,17 @@ function testIsMoraPitchHigh() { [[2, 1], false], [[3, 1], false], - [[0, 2], true], + [[0, 2], false], [[1, 2], true], [[2, 2], false], [[3, 2], false], - [[0, 3], true], + [[0, 3], false], [[1, 3], true], [[2, 3], true], [[3, 3], false], - [[0, 4], true], + [[0, 4], false], [[1, 4], true], [[2, 4], true], [[3, 4], true] diff --git a/test/test-object-property-accessor.js b/test/test-object-property-accessor.js index 0773ba6e..1e694946 100644 --- a/test/test-object-property-accessor.js +++ b/test/test-object-property-accessor.js @@ -40,29 +40,30 @@ function createTestObject() { } -function testGetProperty1() { - const object = createTestObject(); - const accessor = new ObjectPropertyAccessor(object); - +function testGet1() { const data = [ - [[], object], - [['0'], object['0']], - [['value1'], object.value1], - [['value1', 'value2'], object.value1.value2], - [['value1', 'value3'], object.value1.value3], - [['value1', 'value4'], object.value1.value4], - [['value5'], object.value5], - [['value5', 0], object.value5[0]], - [['value5', 1], object.value5[1]], - [['value5', 2], object.value5[2]] + [[], (object) => object], + [['0'], (object) => object['0']], + [['value1'], (object) => object.value1], + [['value1', 'value2'], (object) => object.value1.value2], + [['value1', 'value3'], (object) => object.value1.value3], + [['value1', 'value4'], (object) => object.value1.value4], + [['value5'], (object) => object.value5], + [['value5', 0], (object) => object.value5[0]], + [['value5', 1], (object) => object.value5[1]], + [['value5', 2], (object) => object.value5[2]] ]; - for (const [pathArray, expected] of data) { - assert.strictEqual(accessor.getProperty(pathArray), expected); + for (const [pathArray, getExpected] of data) { + const object = createTestObject(); + const accessor = new ObjectPropertyAccessor(object); + const expected = getExpected(object); + + assert.strictEqual(accessor.get(pathArray), expected); } } -function testGetProperty2() { +function testGet2() { const object = createTestObject(); const accessor = new ObjectPropertyAccessor(object); @@ -89,15 +90,12 @@ function testGetProperty2() { ]; for (const [pathArray, message] of data) { - assert.throws(() => accessor.getProperty(pathArray), {message}); + assert.throws(() => accessor.get(pathArray), {message}); } } -function testSetProperty1() { - const object = createTestObject(); - const accessor = new ObjectPropertyAccessor(object); - +function testSet1() { const testValue = {}; const data = [ ['0'], @@ -112,17 +110,21 @@ function testSetProperty1() { ]; for (const pathArray of data) { - accessor.setProperty(pathArray, testValue); - assert.strictEqual(accessor.getProperty(pathArray), testValue); + const object = createTestObject(); + const accessor = new ObjectPropertyAccessor(object); + + accessor.set(pathArray, testValue); + assert.strictEqual(accessor.get(pathArray), testValue); } } -function testSetProperty2() { +function testSet2() { const object = createTestObject(); const accessor = new ObjectPropertyAccessor(object); const testValue = {}; const data = [ + [[], 'Invalid path'], [[0], 'Invalid path: [0]'], [['0', 'invalid'], 'Invalid path: ["0"].invalid'], [['value1', 'value2', 0], 'Invalid path: value1.value2[0]'], @@ -137,7 +139,127 @@ function testSetProperty2() { ]; for (const [pathArray, message] of data) { - assert.throws(() => accessor.setProperty(pathArray, testValue), {message}); + assert.throws(() => accessor.set(pathArray, testValue), {message}); + } +} + + +function testDelete1() { + const hasOwn = (object, property) => Object.prototype.hasOwnProperty.call(object, property); + + const data = [ + [['0'], (object) => !hasOwn(object, '0')], + [['value1', 'value2'], (object) => !hasOwn(object.value1, 'value2')], + [['value1', 'value3'], (object) => !hasOwn(object.value1, 'value3')], + [['value1', 'value4'], (object) => !hasOwn(object.value1, 'value4')], + [['value1'], (object) => !hasOwn(object, 'value1')], + [['value5'], (object) => !hasOwn(object, 'value5')] + ]; + + for (const [pathArray, validate] of data) { + const object = createTestObject(); + const accessor = new ObjectPropertyAccessor(object); + + accessor.delete(pathArray); + assert.ok(validate(object)); + } +} + +function testDelete2() { + const data = [ + [[], 'Invalid path'], + [[0], 'Invalid path: [0]'], + [['0', 'invalid'], 'Invalid path: ["0"].invalid'], + [['value1', 'value2', 0], 'Invalid path: value1.value2[0]'], + [['value1', 'value3', 'invalid'], 'Invalid path: value1.value3.invalid'], + [['value1', 'value4', 'invalid'], 'Invalid path: value1.value4.invalid'], + [['value1', 'value4', 0], 'Invalid path: value1.value4[0]'], + [['value5', 1, 'invalid'], 'Invalid path: value5[1].invalid'], + [['value5', 2, 'invalid'], 'Invalid path: value5[2].invalid'], + [['value5', 2, 0], 'Invalid path: value5[2][0]'], + [['value5', 2, 0, 'invalid'], 'Invalid path: value5[2][0]'], + [['value5', 2.5], 'Invalid index'], + [['value5', 0], 'Invalid type'], + [['value5', 1], 'Invalid type'], + [['value5', 2], 'Invalid type'] + ]; + + for (const [pathArray, message] of data) { + const object = createTestObject(); + const accessor = new ObjectPropertyAccessor(object); + + assert.throws(() => accessor.delete(pathArray), {message}); + } +} + + +function testSwap1() { + const data = [ + [['0'], true], + [['value1', 'value2'], true], + [['value1', 'value3'], true], + [['value1', 'value4'], true], + [['value1'], false], + [['value5', 0], true], + [['value5', 1], true], + [['value5', 2], true], + [['value5'], false] + ]; + + for (const [pathArray1, compareValues1] of data) { + for (const [pathArray2, compareValues2] of data) { + const object = createTestObject(); + const accessor = new ObjectPropertyAccessor(object); + + const value1a = accessor.get(pathArray1); + const value2a = accessor.get(pathArray2); + + accessor.swap(pathArray1, pathArray2); + + if (!compareValues1 || !compareValues2) { continue; } + + const value1b = accessor.get(pathArray1); + const value2b = accessor.get(pathArray2); + + assert.deepStrictEqual(value1a, value2b); + assert.deepStrictEqual(value2a, value1b); + } + } +} + +function testSwap2() { + const data = [ + [[], [], false, 'Invalid path 1'], + [['0'], [], false, 'Invalid path 2'], + [[], ['0'], false, 'Invalid path 1'], + [[0], ['0'], false, 'Invalid path 1: [0]'], + [['0'], [0], false, 'Invalid path 2: [0]'] + ]; + + for (const [pathArray1, pathArray2, checkRevert, message] of data) { + const object = createTestObject(); + const accessor = new ObjectPropertyAccessor(object); + + let value1a; + let value2a; + if (checkRevert) { + try { + value1a = accessor.get(pathArray1); + value2a = accessor.get(pathArray2); + } catch (e) { + // NOP + } + } + + assert.throws(() => accessor.swap(pathArray1, pathArray2), {message}); + + if (!checkRevert) { continue; } + + const value1b = accessor.get(pathArray1); + const value2b = accessor.get(pathArray2); + + assert.deepStrictEqual(value1a, value1b); + assert.deepStrictEqual(value2a, value2b); } } @@ -272,10 +394,14 @@ function testIsValidPropertyType() { function main() { - testGetProperty1(); - testGetProperty2(); - testSetProperty1(); - testSetProperty2(); + testGet1(); + testGet2(); + testSet1(); + testSet2(); + testDelete1(); + testDelete2(); + testSwap1(); + testSwap2(); testGetPathString1(); testGetPathString2(); testGetPathArray1(); diff --git a/test/yomichan-test.js b/test/yomichan-test.js index 3351ecdf..b4f5ac7c 100644 --- a/test/yomichan-test.js +++ b/test/yomichan-test.js @@ -38,12 +38,17 @@ function createTestDictionaryArchive(dictionary, dictionaryName) { const archive = new (getJSZip())(); for (const fileName of fileNames) { - const source = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: 'utf8'}); - const json = JSON.parse(source); - if (fileName === 'index.json' && typeof dictionaryName === 'string') { - json.title = dictionaryName; + if (/\.json$/.test(fileName)) { + const content = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: 'utf8'}); + const json = JSON.parse(content); + if (fileName === 'index.json' && typeof dictionaryName === 'string') { + json.title = dictionaryName; + } + archive.file(fileName, JSON.stringify(json, null, 0)); + } else { + const content = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: null}); + archive.file(fileName, content); } - archive.file(fileName, JSON.stringify(json, null, 0)); } return archive; |