diff options
95 files changed, 5975 insertions, 4343 deletions
@@ -60,7 +60,7 @@ Import](https://foosoft.net/projects/yomichan-import) page to learn how to conve 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) +* **[JMdict](https://www.edrdg.org/jmdict/edict_doc.html)** (Japanese vocabulary) * [jmdict\_dutch.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_dutch.zip) * [jmdict\_english.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_english.zip) * [jmdict\_french.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_french.zip) @@ -90,7 +90,7 @@ primary language is not English, you may consider also importing the English ver [![](https://foosoft.net/projects/yomichan/img/ui-actions-thumb.png)](https://foosoft.net/projects/yomichan/img/ui-actions.png) -2. Click on the *spanner/monkey wrench* icon in the middle to open the options page. +2. Click on the *spanner/cog* 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 (or enabled), Yomichan will warn you that it is not ready for use by displaying an orange exclamation mark over its diff --git a/ext/bg/background.html b/ext/bg/background.html index ca35a3c6..55380ae7 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -20,7 +20,6 @@ <script src="/mixed/lib/wanakana.min.js"></script> <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> @@ -29,19 +28,19 @@ <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> <script src="/bg/js/clipboard-monitor.js"></script> <script src="/bg/js/conditions.js"></script> + <script src="/bg/js/generic-database.js"></script> <script src="/bg/js/database.js"></script> <script src="/bg/js/dictionary-importer.js"></script> <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/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> + <script src="/bg/js/template-renderer.js"></script> <script src="/bg/js/text-source-map.js"></script> <script src="/bg/js/translator.js"></script> <script src="/bg/js/util.js"></script> diff --git a/ext/bg/context.html b/ext/bg/context.html index 93012d70..1e7e6155 100644 --- a/ext/bg/context.html +++ b/ext/bg/context.html @@ -1,191 +1,60 @@ <!DOCTYPE html> <html lang="en"> - <head> - <meta charset="UTF-8"> - <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"> - <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> - <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"> - <style type="text/css"> - body { - padding: 10px; - } - - h3 { - margin-top: 10px; - } - label { - font-weight: normal; - } - - #mini { - text-align: center; - } - #full { - display: none; - } - .btn-group { - display: flex; - justify-content: center; - margin-top: 10px; - white-space: nowrap; - } - - html:root[data-mode=full] #mini { - display: none; - } - html:root[data-mode=full] #full { - display: initial; - } - - .link-group { - display: block; - line-height: 1.5em; - margin: 0 -10px; - padding: 0.5em 10px; - cursor: pointer; - color: #333; - text-decoration: none; - background-color: transparent; - transition: background-color 0.125s linear 0s; - max-width: none; - } - .link-group:hover, - .link-group:active { - color: #333; - text-decoration: none; - } - .link-group:hover>.link-group-label, - .link-group:active>.link-group-label { - text-decoration: underline; - } - .link-group:hover { - background-color: rgba(0, 0, 0, 0.05); - } - .link-group:active { - background-color: rgba(0, 0, 0, 0.1); - } - .link-group-icon { - width: 16px; - height: 16px; - text-align: center; - vertical-align: middle; - display: inline-block; - margin-right: 0.25em; - } - .link-group-icon>input { - margin: 0; - padding: 0; - } - .link-group-icon>.glyphicon { - top: 0; - } - .link-group-label { - vertical-align: middle; - } - - - .toggle { - width: 60px; - height: 34px; - position: relative; - overflow: hidden; - } - .toggle-group { - position: absolute; - width: 200%; - left: 0; - top: 0; - bottom: 0; - user-select: none; - } - .toggle-group.toggle-group-animated { - transition: transform 0.35s; - } - .toggle-on, - .toggle-off { - position: absolute; - top: 0; - bottom: 0; - margin: 0; - border: 0; - border-radius: 0; - } - .toggle-on { - padding-right: 24px; - left: 0; - right: 50%; - } - .toggle-off { - padding-left: 24px; - left: 50%; - right: 0; - } - .toggle-handle { - position: relative; - margin: 0 auto; - padding-top: 0; - padding-bottom: 0; - height: 100%; - width: 0; - border-width: 0 1px; - } - - .toggle>input[type=checkbox] { - display: none; - } - .toggle>input[type=checkbox]:not(:checked)~.toggle-group { - transform: translateX(-50%); - } - </style> - </head> - <body> - <div id="mini"> - <div> - <label class="btn btn-primary toggle"> - <input type="checkbox" id="enable-search" /> - <div class="toggle-group"> - <span class="btn btn-primary toggle-on">On</span> - <span class="btn btn-default active toggle-off">Off</span> - <span class="btn btn-default toggle-handle"></span> - </div> - </label> - </div> - <div class="btn-group"> - <a title="Search (Alt + Insert) (Middle click to open in new tab)" class="btn btn-default btn-xs action-open-search"><span class="glyphicon glyphicon-search"></span></a> - <a title="Options (Middle click to open in new tab)" class="btn btn-default btn-xs action-open-options"><span class="glyphicon glyphicon-wrench"></span></a> - <a title="Help" class="btn btn-default btn-xs action-open-help"><span class="glyphicon glyphicon-question-sign"></span></a> - </div> - </div> - <div id="full"> - <h3 id="extension-info">Yomichan</h3> - <label class="link-group"> - <span class="link-group-icon"><input type="checkbox" id="enable-search2" /></span><span class="link-group-label">Enable content scanning</span> - </label> - <a class="link-group action-open-options"> - <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Options</span> - </a> - <a class="link-group action-open-search"> - <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Search</span> - </a> - <a class="link-group action-open-help"> - <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Help</span> - </a> +<head> + <meta charset="UTF-8"> + <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"> + <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> + <link rel="stylesheet" type="text/css" href="/bg/css/context.css"> +</head> +<body> + +<div id="mini"> + <label class="toggle"> + <input type="checkbox" id="enable-search"> + <div class="toggle-group"> + <span class="toggle-on">On</span> + <span class="btnx btn-defaulxt toggle-off">Off</span> + <span class="btnx xbtn-default toggle-handle"></span> </div> - - <script src="/mixed/js/core.js"></script> - <script src="/mixed/js/dom.js"></script> - <script src="/mixed/js/api.js"></script> - - <script src="/bg/js/options.js"></script> - <script src="/bg/js/util.js"></script> - - <script src="/bg/js/context-main.js"></script> - </body> + </label> + <div class="nav-button-container"> + <a class="nav-button action-open-search" data-icon="magnifying-glass" title="Search (Alt + Insert) (Middle click to open in new tab)"></a> + <a class="nav-button action-open-options" data-icon="cog" title="Options (Middle click to open in new tab)"></a> + <a class="nav-button action-open-help" data-icon="question-mark" title="Help"></a> + </div> +</div> + +<div id="full"> + <h3 id="extension-info">Yomichan</h3> + <label class="link-group"> + <span class="link-group-icon"><input type="checkbox" id="enable-search2" /></span><span class="link-group-label">Enable content scanning</span> + </label> + <a class="link-group action-open-options"> + <span class="link-group-icon" data-icon="chevron"></span><span class="link-group-label">Options</span> + </a> + <a class="link-group action-open-search"> + <span class="link-group-icon" data-icon="chevron"></span><span class="link-group-label">Search</span> + </a> + <a class="link-group action-open-help"> + <span class="link-group-icon" data-icon="chevron"></span><span class="link-group-label">Help</span> + </a> +</div> + +<script src="/mixed/js/core.js"></script> +<script src="/mixed/js/comm.js"></script> +<script src="/mixed/js/dom.js"></script> +<script src="/mixed/js/api.js"></script> + +<script src="/bg/js/options.js"></script> +<script src="/bg/js/util.js"></script> + +<script src="/bg/js/context-main.js"></script> + +</body> </html> diff --git a/ext/bg/css/context.css b/ext/bg/css/context.css new file mode 100644 index 00000000..2d42dd16 --- /dev/null +++ b/ext/bg/css/context.css @@ -0,0 +1,282 @@ +/* + * 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/>. + */ + +body { + padding: 10px; + margin: 0; + font-family: "Segoe UI", Tahoma, sans-serif; + font-size: 14px; +} + +h3 { + margin: 10px 0; + font-family: inherit; + font-weight: 500; + line-height: 1.1; + font-size: 24px; + color: inherit; +} +label { + font-weight: normal; +} + +#mini { + text-align: center; +} +#full { + display: none; +} +:root[data-mode=full] #mini { + display: none; +} +:root[data-mode=full] #full { + display: initial; +} + + +.link-group { + display: block; + line-height: 1.5em; + margin: 0 -10px; + padding: 0.5em 10px; + cursor: pointer; + color: #333; + text-decoration: none; + background-color: transparent; + transition: background-color 0.125s linear 0s; + max-width: none; +} +.link-group:hover, +.link-group:active { + color: #333; + text-decoration: none; +} +.link-group:hover>.link-group-label, +.link-group:active>.link-group-label { + text-decoration: underline; +} +.link-group:hover { + background-color: rgba(0, 0, 0, 0.05); +} +.link-group:active { + background-color: rgba(0, 0, 0, 0.1); +} +.link-group-icon { + width: 16px; + height: 16px; + text-align: center; + vertical-align: middle; + display: inline-block; + margin-right: 0.25em; +} +.link-group-icon>input { + margin: 0; + padding: 0; +} +.link-group-icon[data-icon=chevron]::after { + content: ""; + display: block; + width: 100%; + height: 100%; + background-image: url(/mixed/img/right-chevron.svg); + background-repeat: no-repeat; + background-position: center center; + background-size: contain; +} +.link-group-label { + vertical-align: middle; +} + +/* Toggle */ +.toggle>input[type=checkbox] { + display: none; +} +.toggle>input[type=checkbox]:not(:checked)~.toggle-group { + transform: translateX(-50%); +} +.toggle { + box-sizing: border-box; + width: 60px; + height: 34px; + position: relative; + overflow: hidden; + border: 1px solid #245580; + border-radius: 4px; + display: inline-block; + padding: 6px 12px; +} +.toggle-group { + position: absolute; + width: 200%; + left: 0; + top: 0; + bottom: 0; + user-select: none; +} +body[data-loaded=true] .toggle-group { + transition: transform 0.35s; +} + +.toggle-on, +.toggle-off, +.toggle-handle { + display: block; + padding: 6px 12px; + font-size: 14px; + font-weight: 400; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + cursor: pointer; +} +.toggle-on, +.toggle-off { + position: absolute; + top: 0; + bottom: 0; + margin: 0; + border: 0; +} + +.toggle-on { + padding-right: 24px; + left: 0; + right: 50%; + color: #ffffff; + background-color: #337ab7; + border-color: #2e6da4; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 3px 5px rgba(0, 0, 0, 0); + background-image: linear-gradient(#337ab7, #265a88); + background-repeat: repeat-x; +} +.toggle-on:focus, +.toggle-on:hover { + background-color: #265a88; + background-image: linear-gradient(#2d65a0, #265a88 50%); +} +.toggle-on:active { + background-color: #204d74; + background-image: none; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} + +.toggle-off { + padding-left: 24px; + left: 50%; + right: 0; +} + +.toggle-handle { + position: relative; + margin: 0 auto; + padding-top: 0; + padding-bottom: 0; + height: 100%; + width: 0; + border-style: solid; + border-width: 0 1px; + border-radius: 4px; + border-color: #cccccc; +} + +.toggle-off, +.toggle-handle { + color: #333333; + text-shadow: 0 1px 0 #ffffff; + background-color: #ffffff; + background-image: linear-gradient(#ffffff, #e0e0e0); + background-repeat: repeat-x; + box-shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075); +} +.toggle-off:focus, +.toggle-off:hover, +.toggle-handle:focus, +.toggle-handle:hover { + background-color: #e6e6e6; + background-image: linear-gradient(#e0e0e0, #e6e6e6 50%); + border-color: #adadad; +} +.toggle-off:active, +.toggle-handle:active { + background-color:#d4d4d4; + background-image: none; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + border-color: #8c8c8c; +} + + +/* Navigation buttons and icons */ +.nav-button-container { + display: flex; + justify-content: center; + margin-top: 10px; + white-space: nowrap; +} +.nav-button { + background-image: linear-gradient(#f8f8f8, #e0e0e0); + border: 1px solid #cccccc; + margin: 0; + padding: 2px 3px; + margin: 0; + cursor: pointer; +} +.nav-button+.nav-button { + margin-left: -1px; +} +.nav-button::after { + content: ""; + display: block; + width: 16px; + height: 16px; + background-position: center center; + background-size: 16px 16px; + background-repeat: no-repeat; + box-sizing: content-box; +} +.nav-button:hover { + z-index: 1; + border-color: #aaaaaa; + background-image: linear-gradient(#e8e8e8, #d0d0d0); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); +} +.nav-button:active { + z-index: 1; + border-color: #808080; + background-image: linear-gradient(#c8c8c8, #e0e0e0); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); +} +.nav-button:focus { + outline: none; +} +.nav-button[data-icon=magnifying-glass]::after { + background-image: url(/mixed/img/magnifying-glass.svg); +} +.nav-button[data-icon=cog]::after { + background-image: url(/mixed/img/cog.svg); +} +.nav-button[data-icon=question-mark]::after { + background-image: url(/mixed/img/question-mark-circle.svg); +} +.nav-button:first-of-type { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} +.nav-button:last-of-type { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css index f55082e7..7659f6f2 100644 --- a/ext/bg/css/settings.css +++ b/ext/bg/css/settings.css @@ -16,9 +16,8 @@ */ -#anki-spinner, #dict-spinner, #dict-import-progress, -.storage-hidden, #storage-spinner { +.storage-hidden { display: none; } diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index 656da989..b56017bc 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -109,7 +109,9 @@ "showPitchAccentDownstepNotation", "showPitchAccentPositionNotation", "showPitchAccentGraph", - "showIframePopupsInRootFrame" + "showIframePopupsInRootFrame", + "useSecurePopupFrameUrl", + "usePopupShadowDom" ], "properties": { "enable": { @@ -247,6 +249,14 @@ "showIframePopupsInRootFrame": { "type": "boolean", "default": false + }, + "useSecurePopupFrameUrl": { + "type": "boolean", + "default": true + }, + "usePopupShadowDom": { + "type": "boolean", + "default": true } } }, @@ -316,7 +326,8 @@ "enablePopupSearch", "enableOnPopupExpressions", "enableOnSearchPage", - "enableSearchTags" + "enableSearchTags", + "layoutAwareScan" ], "properties": { "middleMouse": { @@ -378,6 +389,10 @@ "enableSearchTags": { "type": "boolean", "default": false + }, + "layoutAwareScan": { + "type": "boolean", + "default": false } } }, diff --git a/ext/bg/guide.html b/ext/bg/guide.html index cde520d1..d75a9931 100644 --- a/ext/bg/guide.html +++ b/ext/bg/guide.html @@ -27,7 +27,7 @@ <ol> <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>Click on the <em>cog</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> <li>Click on the <img src="/mixed/img/play-audio.svg" alt> icon to hear the term pronounced by a native speaker.</li> diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js index 76199db7..7fe8962a 100644 --- a/ext/bg/js/anki-note-builder.js +++ b/ext/bg/js/anki-note-builder.js @@ -28,8 +28,9 @@ class AnkiNoteBuilder { const modeOptions = isKanji ? options.anki.kanji : options.anki.terms; const modeOptionsFieldEntries = Object.entries(modeOptions.fields); + const fields = {}; const note = { - fields: {}, + fields, tags, deckName: modeOptions.deck, modelName: modeOptions.model, @@ -38,8 +39,17 @@ class AnkiNoteBuilder { } }; - for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { - note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null); + const formattedFieldValuePromises = []; + for (const [, fieldValue] of modeOptionsFieldEntries) { + const formattedFieldValuePromise = this.formatField(fieldValue, definition, mode, context, options, templates, null); + formattedFieldValuePromises.push(formattedFieldValuePromise); + } + + const formattedFieldValues = await Promise.all(formattedFieldValuePromises); + for (let i = 0, ii = modeOptionsFieldEntries.length; i < ii; ++i) { + const fieldName = modeOptionsFieldEntries[i][0]; + const formattedFieldValue = formattedFieldValues[i]; + fields[fieldName] = formattedFieldValue; } return note; @@ -155,7 +165,7 @@ class AnkiNoteBuilder { } static arrayBufferToBase64(arrayBuffer) { - return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); } static stringReplaceAsync(str, regex, replacer) { diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js index 27e97680..390e1e4d 100644 --- a/ext/bg/js/audio-uri-builder.js +++ b/ext/bg/js/audio-uri-builder.js @@ -82,16 +82,23 @@ class AudioUriBuilder { } async _getUriJpod101Alternate(definition) { - const response = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data'))); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.send(`post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`); + const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'; + const data = `post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`; + const response = await fetch(fetchUrl, { + method: 'POST', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data }); + const responseText = await response.text(); - const dom = new DOMParser().parseFromString(response, 'text/html'); + const dom = new DOMParser().parseFromString(responseText, 'text/html'); for (const row of dom.getElementsByClassName('dc-result-row')) { try { const url = row.querySelector('audio>source[src]').getAttribute('src'); @@ -108,15 +115,18 @@ class AudioUriBuilder { } async _getUriJisho(definition) { - const response = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('GET', `https://jisho.org/search/${definition.expression}`); - xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data'))); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.send(); + const fetchUrl = `https://jisho.org/search/${definition.expression}`; + const response = await fetch(fetchUrl, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' }); + const responseText = await response.text(); - const dom = new DOMParser().parseFromString(response, 'text/html'); + const dom = new DOMParser().parseFromString(responseText, 'text/html'); try { const audio = dom.getElementById(`audio_${definition.expression}:${definition.reading}`); if (audio !== null) { diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js deleted file mode 100644 index 4ac12730..00000000 --- a/ext/bg/js/backend-api-forwarder.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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/>. - */ - - -class BackendApiForwarder { - prepare() { - chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); - } - - _onConnect(port) { - if (port.name !== 'backend-api-forwarder') { return; } - - let tabId; - if (!( - port.sender && - port.sender.tab && - (typeof (tabId = port.sender.tab.id)) === 'number' - )) { - port.disconnect(); - return; - } - - const forwardPort = chrome.tabs.connect(tabId, {name: 'frontend-api-receiver'}); - - port.onMessage.addListener((message) => forwardPort.postMessage(message)); - forwardPort.onMessage.addListener((message) => port.postMessage(message)); - port.onDisconnect.addListener(() => forwardPort.disconnect()); - forwardPort.onDisconnect.addListener(() => port.disconnect()); - } -} diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 20d31efc..344706d1 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -20,7 +20,6 @@ * AnkiNoteBuilder * AudioSystem * AudioUriBuilder - * BackendApiForwarder * ClipboardMonitor * Database * DictionaryImporter @@ -28,10 +27,10 @@ * JsonSchema * Mecab * ObjectPropertyAccessor + * TemplateRenderer * Translator * conditionsTestValue * dictTermsSort - * handlebarsRenderDynamic * jp * optionsLoad * optionsSave @@ -64,22 +63,28 @@ class Backend { audioSystem: this.audioSystem, renderTemplate: this._renderTemplate.bind(this) }); + this._templateRenderer = new TemplateRenderer(); - this.optionsContext = { - depth: 0, - url: window.location.href - }; + const url = (typeof window === 'object' && window !== null ? window.location.href : ''); + this.optionsContext = {depth: 0, url}; - this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); + this.clipboardPasteTarget = ( + typeof document === 'object' && document !== null ? + document.querySelector('#clipboard-paste-target') : + null + ); this.popupWindow = null; - const apiForwarder = new BackendApiForwarder(); - apiForwarder.prepare(); - - this._defaultBrowserActionTitle = null; this._isPrepared = false; this._prepareError = false; + this._preparePromise = null; + this._prepareCompletePromise = new Promise((resolve, reject) => { + this._prepareCompleteResolve = resolve; + this._prepareCompleteReject = reject; + }); + + this._defaultBrowserActionTitle = null; this._badgePrepareDelayTimer = null; this._logErrorLevel = null; @@ -103,6 +108,7 @@ class Backend { ['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)}], + ['getStylesheetContent', {async: true, contentScript: true, handler: this._onApiGetStylesheetContent.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)}], @@ -119,7 +125,9 @@ class Backend { ['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)}] + ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}], + ['getSettings', {async: false, contentScript: true, handler: this._onApiGetSettings.bind(this)}], + ['setAllSettings', {async: true, contentScript: false, handler: this._onApiSetAllSettings.bind(this)}] ]); this._messageHandlersWithProgress = new Map([ ['importDictionaryArchive', {async: true, contentScript: false, handler: this._onApiImportDictionaryArchive.bind(this)}], @@ -134,7 +142,26 @@ class Backend { ]); } - async prepare() { + prepare() { + if (this._preparePromise === null) { + const promise = this._prepareInternal(); + promise.then( + (value) => { + this._isPrepared = true; + this._prepareCompleteResolve(value); + }, + (error) => { + this._prepareError = true; + this._prepareCompleteReject(error); + } + ); + promise.finally(() => this._updateBadge()); + this._preparePromise = promise; + } + return this._prepareCompletePromise; + } + + async _prepareInternal() { try { this._defaultBrowserActionTitle = await this._getBrowserIconTitle(); this._badgePrepareDelayTimer = setTimeout(() => { @@ -143,8 +170,14 @@ class Backend { }, 1000); this._updateBadge(); + yomichan.on('log', this._onLog.bind(this)); + await this.environment.prepare(); - await this.database.prepare(); + try { + await this.database.prepare(); + } catch (e) { + yomichan.logError(e); + } await this.translator.prepare(); await profileConditionsDescriptorPromise; @@ -156,14 +189,6 @@ class Backend { 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)); - const options = this.getOptions(this.optionsContext); if (options.general.showGuide) { chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')}); @@ -174,10 +199,7 @@ class Backend { this._sendMessageAllTabs('backendPrepared'); const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.runtime.sendMessage({action: 'backendPrepared'}, callback); - - this._isPrepared = true; } catch (e) { - this._prepareError = true; yomichan.logError(e); throw e; } finally { @@ -185,15 +207,33 @@ class Backend { clearTimeout(this._badgePrepareDelayTimer); this._badgePrepareDelayTimer = null; } - - this._updateBadge(); } } + prepareComplete() { + return this._prepareCompletePromise; + } + isPrepared() { return this._isPrepared; } + handleCommand(...args) { + return this._runCommand(...args); + } + + handleZoomChange(...args) { + return this._onZoomChange(...args); + } + + handleConnect(...args) { + return this._onConnect(...args); + } + + handleMessage(...args) { + return this.onMessage(...args); + } + _sendMessageAllTabs(action, params={}) { const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.tabs.query({}, (tabs) => { @@ -236,6 +276,45 @@ class Backend { } } + _onConnect(port) { + try { + const match = /^background-cross-frame-communication-port-(\d+)$/.exec(`${port.name}`); + if (match === null) { return; } + + const tabId = (port.sender && port.sender.tab ? port.sender.tab.id : null); + if (typeof tabId !== 'number') { + throw new Error('Port does not have an associated tab ID'); + } + const senderFrameId = port.sender.frameId; + if (typeof tabId !== 'number') { + throw new Error('Port does not have an associated frame ID'); + } + const targetFrameId = parseInt(match[1], 10); + + let forwardPort = chrome.tabs.connect(tabId, {frameId: targetFrameId, name: `cross-frame-communication-port-${senderFrameId}`}); + + const cleanup = () => { + this.checkLastError(chrome.runtime.lastError); + if (forwardPort !== null) { + forwardPort.disconnect(); + forwardPort = null; + } + if (port !== null) { + port.disconnect(); + port = null; + } + }; + + port.onMessage.addListener((message) => { forwardPort.postMessage(message); }); + forwardPort.onMessage.addListener((message) => { port.postMessage(message); }); + port.onDisconnect.addListener(cleanup); + forwardPort.onDisconnect.addListener(cleanup); + } catch (e) { + port.disconnect(); + yomichan.logError(e); + } + } + _onClipboardText({text}) { this._onCommandSearch({mode: 'popup', query: text}); } @@ -245,6 +324,14 @@ class Backend { chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback); } + _onLog({level}) { + const levelValue = this._getErrorLevelValue(level); + if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } + + this._logErrorLevel = level; + this._updateBadge(); + } + applyOptions() { const options = this.getOptions(this.optionsContext); this._updateBadge(); @@ -274,15 +361,6 @@ class Backend { return useSchema ? JsonSchema.createProxy(options, this.optionsSchema) : options; } - setFullOptions(options) { - try { - this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options)); - } catch (e) { - // This shouldn't happen, but catch errors just in case of bugs - yomichan.logError(e); - } - } - getOptions(optionsContext, useSchema=false) { return this.getProfile(optionsContext, useSchema).options; } @@ -506,13 +584,14 @@ class Backend { const states = []; try { - const notes = []; + const notePromises = []; for (const definition of definitions) { for (const mode of modes) { - const note = await this.ankiNoteBuilder.createNote(definition, mode, context, options, templates); - notes.push(note); + const notePromise = this.ankiNoteBuilder.createNote(definition, mode, context, options, templates); + notePromises.push(notePromise); } } + const notes = await Promise.all(notePromises); const cannotAdd = []; const results = await this.anki.canAddNotes(notes); @@ -641,6 +720,13 @@ class Backend { }); } + async _onApiGetStylesheetContent({url}) { + if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { + throw new Error('Invalid URL'); + } + return await requestText(url, 'GET'); + } + _onApiGetEnvironmentInfo() { return this.environment.getInfo(); } @@ -663,6 +749,9 @@ class Backend { return await navigator.clipboard.readText(); } else { const clipboardPasteTarget = this.clipboardPasteTarget; + if (clipboardPasteTarget === null) { + throw new Error('Reading the clipboard is not supported in this context'); + } clipboardPasteTarget.value = ''; clipboardPasteTarget.focus(); document.execCommand('paste'); @@ -744,12 +833,6 @@ class Backend { _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() { @@ -791,8 +874,8 @@ class Backend { const results = []; for (const target of targets) { try { - this._modifySetting(target); - results.push({result: true}); + const result = this._modifySetting(target); + results.push({result: utilIsolate(result)}); } catch (e) { results.push({error: errorToJson(e)}); } @@ -801,10 +884,29 @@ class Backend { return results; } + _onApiGetSettings({targets}) { + const results = []; + for (const target of targets) { + try { + const result = this._getSetting(target); + results.push({result: utilIsolate(result)}); + } catch (e) { + results.push({error: errorToJson(e)}); + } + } + return results; + } + + async _onApiSetAllSettings({value, source}) { + this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, value); + await this._onApiOptionsSave({source}); + } + // Command handlers _createActionListenerPort(port, sender, handlers) { let hasStarted = false; + let messageString = ''; const onProgress = (...data) => { try { @@ -815,12 +917,34 @@ class Backend { } }; - const onMessage = async ({action, params}) => { + const onMessage = (message) => { if (hasStarted) { return; } - hasStarted = true; - port.onMessage.removeListener(onMessage); try { + const {action, data} = message; + switch (action) { + case 'fragment': + messageString += data; + break; + case 'invoke': + { + hasStarted = true; + port.onMessage.removeListener(onMessage); + + const messageData = JSON.parse(messageString); + messageString = null; + onMessageComplete(messageData); + } + break; + } + } catch (e) { + cleanup(e); + } + }; + + const onMessageComplete = async (message) => { + try { + const {action, params} = message; port.postMessage({type: 'ack'}); const messageHandler = handlers.get(action); @@ -837,25 +961,29 @@ class Backend { 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(); + cleanup(e); } }; - const cleanup = () => { + const onDisconnect = () => { + cleanup(null); + }; + + const cleanup = (error) => { if (port === null) { return; } + if (error !== null) { + port.postMessage({type: 'error', data: errorToJson(error)}); + } if (!hasStarted) { port.onMessage.removeListener(onMessage); } - port.onDisconnect.removeListener(cleanup); + port.onDisconnect.removeListener(onDisconnect); port = null; handlers = null; }; port.onMessage.addListener(onMessage); - port.onDisconnect.addListener(cleanup); + port.onDisconnect.addListener(onDisconnect); } _getErrorLevelValue(errorLevel) { @@ -951,13 +1079,8 @@ class Backend { } async _onCommandToggle() { - const optionsContext = { - depth: 0, - url: window.location.href - }; const source = 'popup'; - - const options = this.getOptions(optionsContext); + const options = this.getOptions(this.optionsContext); options.general.enable = !options.general.enable; await this._onApiOptionsSave({source}); } @@ -977,45 +1100,53 @@ class Backend { } } - async _modifySetting(target) { + _getSetting(target) { + const options = this._getModifySettingObject(target); + const accessor = new ObjectPropertyAccessor(options); + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + return accessor.get(ObjectPropertyAccessor.getPathArray(path)); + } + + _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; + { + const {path, value} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + const pathArray = ObjectPropertyAccessor.getPathArray(path); + accessor.set(pathArray, value); + return accessor.get(pathArray); + } case 'delete': - { - const {path} = target; - if (typeof path !== 'string') { throw new Error('Invalid path'); } - accessor.delete(ObjectPropertyAccessor.getPathArray(path)); - } - break; + { + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + accessor.delete(ObjectPropertyAccessor.getPathArray(path)); + return true; + } 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; + { + 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)); + return true; + } 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; + { + 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'); } + return array.splice(start, deleteCount, ...items); + } default: throw new Error(`Unknown action: ${action}`); } @@ -1113,7 +1244,7 @@ class Backend { } async _renderTemplate(template, data) { - return handlebarsRenderDynamic(template, data); + return await this._templateRenderer.render(template, data); } _getTemplates(options) { @@ -1211,3 +1342,57 @@ class Backend { } } } + +class BackendEventHandler { + constructor(backend) { + this._backend = backend; + } + + prepare() { + if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { + const onCommand = this._createGenericEventHandler((...args) => this._backend.handleCommand(...args)); + chrome.commands.onCommand.addListener(onCommand); + } + + if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { + const onZoomChange = this._createGenericEventHandler((...args) => this._backend.handleZoomChange(...args)); + chrome.tabs.onZoomChange.addListener(onZoomChange); + } + + const onConnect = this._createGenericEventHandler((...args) => this._backend.handleConnect(...args)); + chrome.runtime.onConnect.addListener(onConnect); + + const onMessage = this._onMessage.bind(this); + chrome.runtime.onMessage.addListener(onMessage); + } + + // Event handlers + + _createGenericEventHandler(handler) { + return this._onGenericEvent.bind(this, handler); + } + + _onGenericEvent(handler, ...args) { + if (this._backend.isPrepared()) { + handler(...args); + return; + } + + this._backend.prepareComplete().then( + () => { handler(...args); }, + () => {} // NOP + ); + } + + _onMessage(message, sender, sendResponse) { + if (this._backend.isPrepared()) { + return this._backend.handleMessage(message, sender, sendResponse); + } + + this._backend.prepareComplete().then( + () => { this._backend.handleMessage(message, sender, sendResponse); }, + () => { sendResponse(); } // NOP + ); + return true; + } +} diff --git a/ext/bg/js/background-main.js b/ext/bg/js/background-main.js index 24117f4e..9dd447c4 100644 --- a/ext/bg/js/background-main.js +++ b/ext/bg/js/background-main.js @@ -17,9 +17,15 @@ /* global * Backend + * BackendEventHandler */ -(async () => { - window.yomichanBackend = new Backend(); - await window.yomichanBackend.prepare(); +(() => { + const backend = new Backend(); + const backendEventHandler = new BackendEventHandler(backend); + backendEventHandler.prepare(); + if (typeof window === 'object' && window !== null) { + window.yomichanBackend = backend; + } + backend.prepare(); })(); diff --git a/ext/bg/js/context-main.js b/ext/bg/js/context-main.js index dbba0272..4a2ea168 100644 --- a/ext/bg/js/context-main.js +++ b/ext/bg/js/context-main.js @@ -16,11 +16,7 @@ */ /* global - * apiCommandExec - * apiForwardLogsToBackend - * apiGetEnvironmentInfo - * apiLogIndicatorClear - * apiOptionsGet + * api */ function showExtensionInfo() { @@ -36,12 +32,12 @@ function setupButtonEvents(selector, command, url) { for (const node of nodes) { node.addEventListener('click', (e) => { if (e.button !== 0) { return; } - apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); + api.commandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); e.preventDefault(); }, false); node.addEventListener('auxclick', (e) => { if (e.button !== 1) { return; } - apiCommandExec(command, {mode: 'newTab'}); + api.commandExec(command, {mode: 'newTab'}); e.preventDefault(); }, false); @@ -54,14 +50,14 @@ function setupButtonEvents(selector, command, url) { } async function mainInner() { - apiForwardLogsToBackend(); + api.forwardLogsToBackend(); await yomichan.prepare(); - await apiLogIndicatorClear(); + await api.logIndicatorClear(); showExtensionInfo(); - apiGetEnvironmentInfo().then(({browser}) => { + api.getEnvironmentInfo().then(({browser}) => { // Firefox mobile opens this page as a full webpage. document.documentElement.dataset.mode = (browser === 'firefox-mobile' ? 'full' : 'mini'); }); @@ -70,25 +66,23 @@ async function mainInner() { setupButtonEvents('.action-open-search', 'search', chrome.runtime.getURL('/bg/search.html')); setupButtonEvents('.action-open-options', 'options', chrome.runtime.getURL(manifest.options_ui.page)); - setupButtonEvents('.action-open-help', 'help'); + setupButtonEvents('.action-open-help', 'help', 'https://foosoft.net/projects/yomichan/'); const optionsContext = { depth: 0, url: window.location.href }; - apiOptionsGet(optionsContext).then((options) => { + api.optionsGet(optionsContext).then((options) => { const toggle = document.querySelector('#enable-search'); toggle.checked = options.general.enable; - toggle.addEventListener('change', () => apiCommandExec('toggle'), false); + toggle.addEventListener('change', () => api.commandExec('toggle'), false); const toggle2 = document.querySelector('#enable-search2'); toggle2.checked = options.general.enable; - toggle2.addEventListener('change', () => apiCommandExec('toggle'), false); + toggle2.addEventListener('change', () => api.commandExec('toggle'), false); setTimeout(() => { - for (const n of document.querySelectorAll('.toggle-group')) { - n.classList.add('toggle-group-animated'); - } + document.body.dataset.loaded = 'true'; }, 10); }); } diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index 930cd0d0..47f1ebdd 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -16,423 +16,422 @@ */ /* global + * GenericDatabase * dictFieldSplit */ class Database { constructor() { - this.db = null; + this._db = new GenericDatabase(); + this._dbName = 'dict'; this._schemas = new Map(); } // Public async prepare() { - if (this.db !== null) { - throw new Error('Database already initialized'); - } - - try { - this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => { - Database._upgrade(db, transaction, oldVersion, [ - { - version: 2, - stores: { - terms: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'expression', 'reading'] - }, - kanji: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'character'] - }, - tagMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary'] - }, - dictionaries: { - primaryKey: {autoIncrement: true}, - indices: ['title', 'version'] - } + await this._db.open( + this._dbName, + 60, + [ + { + version: 20, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading'] + }, + kanji: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'character'] + }, + tagMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary'] + }, + dictionaries: { + primaryKey: {autoIncrement: true}, + indices: ['title', 'version'] } - }, - { - version: 3, - stores: { - termMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'expression'] - }, - kanjiMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'character'] - }, - tagMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'name'] - } + } + }, + { + version: 30, + stores: { + termMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'expression'] + }, + kanjiMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'character'] + }, + tagMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'name'] } - }, - { - version: 4, - stores: { - terms: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'expression', 'reading', 'sequence'] - } + } + }, + { + version: 40, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading', 'sequence'] } - }, - { - version: 5, - stores: { - terms: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] - } + } + }, + { + version: 50, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] } - }, - { - version: 6, - stores: { - media: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'path'] - } + } + }, + { + version: 60, + stores: { + media: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'path'] } } - ]); - }); - return true; - } catch (e) { - yomichan.logError(e); - return false; - } + } + ] + ); } async close() { - this._validate(); - this.db.close(); - this.db = null; + this._db.close(); } isPrepared() { - return this.db !== null; + return this._db.isOpen(); } async purge() { - this._validate(); - - this.db.close(); - await Database._deleteDatabase(this.db.name); - this.db = null; - + if (this._db.isOpening()) { + throw new Error('Cannot purge database while opening'); + } + if (this._db.isOpen()) { + this._db.close(); + } + await GenericDatabase.deleteDatabase(this._dbName); await this.prepare(); } async deleteDictionary(dictionaryName, progressSettings, onProgress) { - this._validate(); - const targets = [ ['dictionaries', 'title'], ['kanji', 'dictionary'], ['kanjiMeta', 'dictionary'], ['terms', 'dictionary'], ['termMeta', 'dictionary'], - ['tagMeta', 'dictionary'] + ['tagMeta', 'dictionary'], + ['media', 'dictionary'] ]; - const promises = []; + + const {rate} = progressSettings; const progressData = { count: 0, processed: 0, storeCount: targets.length, storesProcesed: 0 }; - let progressRate = (typeof progressSettings === 'object' && progressSettings !== null ? progressSettings.rate : 0); - if (typeof progressRate !== 'number' || progressRate <= 0) { - progressRate = 1000; - } - - for (const [objectStoreName, index] of targets) { - const dbTransaction = this.db.transaction([objectStoreName], 'readwrite'); - const dbObjectStore = dbTransaction.objectStore(objectStoreName); - const dbIndex = dbObjectStore.index(index); - const only = IDBKeyRange.only(dictionaryName); - promises.push(Database._deleteValues(dbObjectStore, dbIndex, only, onProgress, progressData, progressRate)); - } - - await Promise.all(promises); - } - - async findTermsBulk(termList, dictionaries, wildcard) { - this._validate(); - const promises = []; - const visited = new Set(); - const results = []; - const processRow = (row, index) => { - if (dictionaries.has(row.dictionary) && !visited.has(row.id)) { - visited.add(row.id); - results.push(Database._createTerm(row, index)); + const filterKeys = (keys) => { + ++progressData.storesProcesed; + progressData.count += keys.length; + onProgress(progressData); + return keys; + }; + const onProgress2 = () => { + const processed = progressData.processed + 1; + progressData.processed = processed; + if ((processed % rate) === 0 || processed === progressData.count) { + onProgress(progressData); } }; - const useWildcard = !!wildcard; - const prefixWildcard = wildcard === 'prefix'; - - const dbTransaction = this.db.transaction(['terms'], 'readonly'); - const dbTerms = dbTransaction.objectStore('terms'); - const dbIndex1 = dbTerms.index(prefixWildcard ? 'expressionReverse' : 'expression'); - const dbIndex2 = dbTerms.index(prefixWildcard ? 'readingReverse' : 'reading'); - - for (let i = 0; i < termList.length; ++i) { - const term = prefixWildcard ? stringReverse(termList[i]) : termList[i]; - const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term); - promises.push( - Database._getAll(dbIndex1, query, i, processRow), - Database._getAll(dbIndex2, query, i, processRow) - ); + const promises = []; + for (const [objectStoreName, indexName] of targets) { + const query = IDBKeyRange.only(dictionaryName); + const promise = this._db.bulkDelete(objectStoreName, indexName, query, filterKeys, onProgress2); + promises.push(promise); } - await Promise.all(promises); - - return results; } - async findTermsExactBulk(termList, readingList, dictionaries) { - this._validate(); - - const promises = []; - const results = []; - const processRow = (row, index) => { - if (row.reading === readingList[index] && dictionaries.has(row.dictionary)) { - results.push(Database._createTerm(row, index)); + findTermsBulk(termList, dictionaries, wildcard) { + return new Promise((resolve, reject) => { + const results = []; + const count = termList.length; + if (count === 0) { + resolve(results); + return; } - }; - - const dbTransaction = this.db.transaction(['terms'], 'readonly'); - const dbTerms = dbTransaction.objectStore('terms'); - const dbIndex = dbTerms.index('expression'); - for (let i = 0; i < termList.length; ++i) { - const only = IDBKeyRange.only(termList[i]); - promises.push(Database._getAll(dbIndex, only, i, processRow)); - } - - await Promise.all(promises); + const visited = new Set(); + const useWildcard = !!wildcard; + const prefixWildcard = wildcard === 'prefix'; + + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index1 = terms.index(prefixWildcard ? 'expressionReverse' : 'expression'); + const index2 = terms.index(prefixWildcard ? 'readingReverse' : 'reading'); + + const count2 = count * 2; + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const term = prefixWildcard ? stringReverse(termList[i]) : termList[i]; + const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term); + + const onGetAll = (rows) => { + for (const row of rows) { + if (dictionaries.has(row.dictionary) && !visited.has(row.id)) { + visited.add(row.id); + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count2) { + resolve(results); + } + }; - return results; + this._db.getAll(index1, query, onGetAll, reject); + this._db.getAll(index2, query, onGetAll, reject); + } + }); } - async findTermsBySequenceBulk(sequenceList, mainDictionary) { - this._validate(); - - const promises = []; - const results = []; - const processRow = (row, index) => { - if (row.dictionary === mainDictionary) { - results.push(Database._createTerm(row, index)); + findTermsExactBulk(termList, readingList, dictionaries) { + return new Promise((resolve, reject) => { + const results = []; + const count = termList.length; + if (count === 0) { + resolve(results); + return; } - }; - - const dbTransaction = this.db.transaction(['terms'], 'readonly'); - const dbTerms = dbTransaction.objectStore('terms'); - const dbIndex = dbTerms.index('sequence'); - - for (let i = 0; i < sequenceList.length; ++i) { - const only = IDBKeyRange.only(sequenceList[i]); - promises.push(Database._getAll(dbIndex, only, i, processRow)); - } - - await Promise.all(promises); - return results; - } - - async findTermMetaBulk(termList, dictionaries) { - return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, Database._createTermMeta); - } + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index = terms.index('expression'); - async findKanjiBulk(kanjiList, dictionaries) { - return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, Database._createKanji); - } + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const reading = readingList[i]; + const query = IDBKeyRange.only(termList[i]); - async findKanjiMetaBulk(kanjiList, dictionaries) { - return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, Database._createKanjiMeta); - } + const onGetAll = (rows) => { + for (const row of rows) { + if (row.reading === reading && dictionaries.has(row.dictionary)) { + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; - async findTagForTitle(name, title) { - this._validate(); - - let result = null; - const dbTransaction = this.db.transaction(['tagMeta'], 'readonly'); - const dbTerms = dbTransaction.objectStore('tagMeta'); - const dbIndex = dbTerms.index('name'); - const only = IDBKeyRange.only(name); - await Database._getAll(dbIndex, only, null, (row) => { - if (title === row.dictionary) { - result = row; + this._db.getAll(index, query, onGetAll, reject); } }); - - 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); + findTermsBySequenceBulk(sequenceList, mainDictionary) { + return new Promise((resolve, reject) => { + const results = []; + const count = sequenceList.length; + if (count === 0) { + resolve(results); + return; } - }; - const transaction = this.db.transaction(['media'], 'readonly'); - const objectStore = transaction.objectStore('media'); - const index = objectStore.index('path'); + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index = terms.index('sequence'); - 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)); - } + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const query = IDBKeyRange.only(sequenceList[i]); - await Promise.all(promises); + const onGetAll = (rows) => { + for (const row of rows) { + if (row.dictionary === mainDictionary) { + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; - return results; + this._db.getAll(index, query, onGetAll, reject); + } + }); } - async getDictionaryInfo() { - this._validate(); + findTermMetaBulk(termList, dictionaries) { + return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, this._createTermMeta.bind(this)); + } - const results = []; - const dbTransaction = this.db.transaction(['dictionaries'], 'readonly'); - const dbDictionaries = dbTransaction.objectStore('dictionaries'); + findKanjiBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, this._createKanji.bind(this)); + } - await Database._getAll(dbDictionaries, null, null, (info) => results.push(info)); + findKanjiMetaBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, this._createKanjiMeta.bind(this)); + } - return results; + findTagForTitle(name, title) { + const query = IDBKeyRange.only(name); + return this._db.find('tagMeta', 'name', query, (row) => (row.dictionary === title), null); } - async getDictionaryCounts(dictionaryNames, getTotal) { - this._validate(); + getMedia(targets) { + return new Promise((resolve, reject) => { + const count = targets.length; + const results = new Array(count).fill(null); + if (count === 0) { + resolve(results); + return; + } - const objectStoreNames = [ - 'kanji', - 'kanjiMeta', - 'terms', - 'termMeta', - 'tagMeta' - ]; - const dbCountTransaction = this.db.transaction(objectStoreNames, 'readonly'); - - const targets = []; - for (const objectStoreName of objectStoreNames) { - targets.push([ - objectStoreName, - dbCountTransaction.objectStore(objectStoreName).index('dictionary') - ]); - } + let completeCount = 0; + const transaction = this._db.transaction(['media'], 'readonly'); + const objectStore = transaction.objectStore('media'); + const index = objectStore.index('path'); - // Query is required for Edge, otherwise index.count throws an exception. - const query1 = IDBKeyRange.lowerBound('', false); - const totalPromise = getTotal ? Database._getCounts(targets, query1) : null; - - const counts = []; - const countPromises = []; - for (let i = 0; i < dictionaryNames.length; ++i) { - counts.push(null); - const index = i; - const query2 = IDBKeyRange.only(dictionaryNames[i]); - const countPromise = Database._getCounts(targets, query2).then((v) => counts[index] = v); - countPromises.push(countPromise); - } - await Promise.all(countPromises); + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const {path, dictionaryName} = targets[i]; + const query = IDBKeyRange.only(path); - const result = {counts}; - if (totalPromise !== null) { - result.total = await totalPromise; - } - return result; + const onGetAll = (rows) => { + for (const row of rows) { + if (row.dictionary !== dictionaryName) { continue; } + results[inputIndex] = this._createMedia(row, inputIndex); + } + if (++completeCount >= count) { + resolve(results); + } + }; + + this._db.getAll(index, query, onGetAll, reject); + } + }); } - async dictionaryExists(title) { - this._validate(); - const transaction = this.db.transaction(['dictionaries'], 'readonly'); - const index = transaction.objectStore('dictionaries').index('title'); - const query = IDBKeyRange.only(title); - const count = await Database._getCount(index, query); - return count > 0; + getDictionaryInfo() { + return new Promise((resolve, reject) => { + const transaction = this._db.transaction(['dictionaries'], 'readonly'); + const objectStore = transaction.objectStore('dictionaries'); + this._db.getAll(objectStore, null, resolve, reject); + }); } - bulkAdd(objectStoreName, items, start, count) { + getDictionaryCounts(dictionaryNames, getTotal) { return new Promise((resolve, reject) => { - const transaction = this.db.transaction([objectStoreName], 'readwrite'); - const objectStore = transaction.objectStore(objectStoreName); + const targets = [ + ['kanji', 'dictionary'], + ['kanjiMeta', 'dictionary'], + ['terms', 'dictionary'], + ['termMeta', 'dictionary'], + ['tagMeta', 'dictionary'], + ['media', 'dictionary'] + ]; + const objectStoreNames = targets.map(([objectStoreName]) => objectStoreName); + const transaction = this._db.transaction(objectStoreNames, 'readonly'); + const databaseTargets = targets.map(([objectStoreName, indexName]) => { + const objectStore = transaction.objectStore(objectStoreName); + const index = objectStore.index(indexName); + return {objectStore, index}; + }); - if (start + count > items.length) { - count = items.length - start; + const countTargets = []; + if (getTotal) { + for (const {objectStore} of databaseTargets) { + countTargets.push([objectStore, null]); + } } - - if (count <= 0) { - resolve(); - return; + for (const dictionaryName of dictionaryNames) { + const query = IDBKeyRange.only(dictionaryName); + for (const {index} of databaseTargets) { + countTargets.push([index, query]); + } } - const end = start + count; - let completedCount = 0; - const onError = (e) => reject(e); - const onSuccess = () => { - if (++completedCount >= count) { - resolve(); + const onCountComplete = (results) => { + const resultCount = results.length; + const targetCount = targets.length; + const counts = []; + for (let i = 0; i < resultCount; i += targetCount) { + const countGroup = {}; + for (let j = 0; j < targetCount; ++j) { + countGroup[targets[j][0]] = results[i + j]; + } + counts.push(countGroup); } + const total = getTotal ? counts.shift() : null; + resolve({total, counts}); }; - for (let i = start; i < end; ++i) { - const request = objectStore.add(items[i]); - request.onerror = onError; - request.onsuccess = onSuccess; - } + this._db.bulkCount(countTargets, onCountComplete, reject); }); } - // Private + async dictionaryExists(title) { + const query = IDBKeyRange.only(title); + const result = await this._db.find('dictionaries', 'title', query); + return typeof result !== 'undefined'; + } - _validate() { - if (this.db === null) { - throw new Error('Database not initialized'); - } + bulkAdd(objectStoreName, items, start, count) { + return this._db.bulkAdd(objectStoreName, items, start, count); } - async _findGenericBulk(tableName, indexName, indexValueList, dictionaries, createResult) { - this._validate(); + // Private - const promises = []; - const results = []; - const processRow = (row, index) => { - if (dictionaries.has(row.dictionary)) { - results.push(createResult(row, index)); + async _findGenericBulk(objectStoreName, indexName, indexValueList, dictionaries, createResult) { + return new Promise((resolve, reject) => { + const results = []; + const count = indexValueList.length; + if (count === 0) { + resolve(results); + return; } - }; - const dbTransaction = this.db.transaction([tableName], 'readonly'); - const dbTerms = dbTransaction.objectStore(tableName); - const dbIndex = dbTerms.index(indexName); + const transaction = this._db.transaction([objectStoreName], 'readonly'); + const terms = transaction.objectStore(objectStoreName); + const index = terms.index(indexName); - for (let i = 0; i < indexValueList.length; ++i) { - const only = IDBKeyRange.only(indexValueList[i]); - promises.push(Database._getAll(dbIndex, only, i, processRow)); - } + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const query = IDBKeyRange.only(indexValueList[i]); - await Promise.all(promises); + const onGetAll = (rows) => { + for (const row of rows) { + if (dictionaries.has(row.dictionary)) { + results.push(createResult(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; - return results; + this._db.getAll(index, query, onGetAll, reject); + } + }); } - static _createTerm(row, index) { + _createTerm(row, index) { return { index, expression: row.expression, @@ -448,7 +447,7 @@ class Database { }; } - static _createKanji(row, index) { + _createKanji(row, index) { return { index, character: row.character, @@ -461,193 +460,15 @@ class Database { }; } - static _createTermMeta({expression, mode, data, dictionary}, index) { + _createTermMeta({expression, mode, data, dictionary}, index) { return {expression, mode, data, dictionary, index}; } - static _createKanjiMeta({character, mode, data, dictionary}, index) { + _createKanjiMeta({character, mode, data, dictionary}, index) { return {character, mode, data, dictionary, index}; } - static _createMedia(row, index) { + _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); - } - - static _getAllFast(dbIndex, query, context, processRow) { - return new Promise((resolve, reject) => { - const request = dbIndex.getAll(query); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => { - for (const row of e.target.result) { - processRow(row, context); - } - resolve(); - }; - }); - } - - static _getAllUsingCursor(dbIndex, query, context, processRow) { - return new Promise((resolve, reject) => { - const request = dbIndex.openCursor(query, 'next'); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => { - const cursor = e.target.result; - if (cursor) { - processRow(cursor.value, context); - cursor.continue(); - } else { - resolve(); - } - }; - }); - } - - static _getCounts(targets, query) { - const countPromises = []; - const counts = {}; - for (const [objectStoreName, index] of targets) { - const n = objectStoreName; - const countPromise = Database._getCount(index, query).then((count) => counts[n] = count); - countPromises.push(countPromise); - } - return Promise.all(countPromises).then(() => counts); - } - - static _getCount(dbIndex, query) { - return new Promise((resolve, reject) => { - const request = dbIndex.count(query); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => resolve(e.target.result); - }); - } - - static _getAllKeys(dbIndex, query) { - const fn = typeof dbIndex.getAllKeys === 'function' ? Database._getAllKeysFast : Database._getAllKeysUsingCursor; - return fn(dbIndex, query); - } - - static _getAllKeysFast(dbIndex, query) { - return new Promise((resolve, reject) => { - const request = dbIndex.getAllKeys(query); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => resolve(e.target.result); - }); - } - - static _getAllKeysUsingCursor(dbIndex, query) { - return new Promise((resolve, reject) => { - const primaryKeys = []; - const request = dbIndex.openKeyCursor(query, 'next'); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => { - const cursor = e.target.result; - if (cursor) { - primaryKeys.push(cursor.primaryKey); - cursor.continue(); - } else { - resolve(primaryKeys); - } - }; - }); - } - - static async _deleteValues(dbObjectStore, dbIndex, query, onProgress, progressData, progressRate) { - const hasProgress = (typeof onProgress === 'function'); - const count = await Database._getCount(dbIndex, query); - ++progressData.storesProcesed; - progressData.count += count; - if (hasProgress) { - onProgress(progressData); - } - - const onValueDeleted = ( - hasProgress ? - () => { - const p = ++progressData.processed; - if ((p % progressRate) === 0 || p === progressData.count) { - onProgress(progressData); - } - } : - () => {} - ); - - const promises = []; - const primaryKeys = await Database._getAllKeys(dbIndex, query); - for (const key of primaryKeys) { - const promise = Database._deleteValue(dbObjectStore, key).then(onValueDeleted); - promises.push(promise); - } - - await Promise.all(promises); - } - - static _deleteValue(dbObjectStore, key) { - return new Promise((resolve, reject) => { - const request = dbObjectStore.delete(key); - request.onerror = (e) => reject(e); - request.onsuccess = () => resolve(); - }); - } - - static _open(name, version, onUpgradeNeeded) { - return new Promise((resolve, reject) => { - const request = window.indexedDB.open(name, version * 10); - - request.onupgradeneeded = (event) => { - try { - request.transaction.onerror = (e) => reject(e); - onUpgradeNeeded(request.result, request.transaction, event.oldVersion / 10, event.newVersion / 10); - } catch (e) { - reject(e); - } - }; - - request.onerror = (e) => reject(e); - request.onsuccess = () => resolve(request.result); - }); - } - - static _upgrade(db, transaction, oldVersion, upgrades) { - for (const {version, stores} of upgrades) { - if (oldVersion >= version) { continue; } - - const objectStoreNames = Object.keys(stores); - for (const objectStoreName of objectStoreNames) { - const {primaryKey, indices} = stores[objectStoreName]; - - const objectStoreNames2 = transaction.objectStoreNames || db.objectStoreNames; - const objectStore = ( - Database._listContains(objectStoreNames2, objectStoreName) ? - transaction.objectStore(objectStoreName) : - db.createObjectStore(objectStoreName, primaryKey) - ); - - for (const indexName of indices) { - if (Database._listContains(objectStore.indexNames, indexName)) { continue; } - - objectStore.createIndex(indexName, indexName, {}); - } - } - } - } - - static _deleteDatabase(dbName) { - return new Promise((resolve, reject) => { - const request = indexedDB.deleteDatabase(dbName); - request.onerror = (e) => reject(e); - request.onsuccess = () => resolve(); - }); - } - - static _listContains(list, value) { - for (let i = 0, ii = list.length; i < ii; ++i) { - if (list[i] === value) { return true; } - } - return false; - } } diff --git a/ext/bg/js/generic-database.js b/ext/bg/js/generic-database.js new file mode 100644 index 00000000..a82ad650 --- /dev/null +++ b/ext/bg/js/generic-database.js @@ -0,0 +1,327 @@ +/* + * 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 GenericDatabase { + constructor() { + this._db = null; + this._isOpening = false; + } + + // Public + + async open(databaseName, version, structure) { + if (this._db !== null) { + throw new Error('Database already open'); + } + if (this._isOpening) { + throw new Error('Already opening'); + } + + try { + this._isOpening = true; + this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => { + this._upgrade(db, transaction, oldVersion, structure); + }); + } finally { + this._isOpening = false; + } + } + + close() { + if (this._db === null) { + throw new Error('Database is not open'); + } + + this._db.close(); + this._db = null; + } + + isOpening() { + return this._isOpening; + } + + isOpen() { + return this._db !== null; + } + + transaction(storeNames, mode) { + if (this._db === null) { + throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); + } + return this._db.transaction(storeNames, mode); + } + + bulkAdd(objectStoreName, items, start, count) { + return new Promise((resolve, reject) => { + if (start + count > items.length) { + count = items.length - start; + } + + if (count <= 0) { + resolve(); + return; + } + + const end = start + count; + let completedCount = 0; + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + if (++completedCount >= count) { + resolve(); + } + }; + + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + for (let i = start; i < end; ++i) { + const request = objectStore.add(items[i]); + request.onerror = onError; + request.onsuccess = onSuccess; + } + }); + } + + getAll(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + getAllKeys(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllKeysFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + find(objectStoreName, indexName, query, predicate=null, defaultValue) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readonly'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + const value = cursor.value; + if (typeof predicate !== 'function' || predicate(value)) { + resolve(value); + } else { + cursor.continue(); + } + } else { + resolve(defaultValue); + } + }; + }); + } + + bulkCount(targets, resolve, reject) { + const targetCount = targets.length; + if (targetCount <= 0) { + resolve(); + return; + } + + let completedCount = 0; + const results = new Array(targetCount).fill(null); + + const onError = (e) => reject(e.target.error); + const onSuccess = (e, index) => { + const count = e.target.result; + results[index] = count; + if (++completedCount >= targetCount) { + resolve(results); + } + }; + + for (let i = 0; i < targetCount; ++i) { + const index = i; + const [objectStoreOrIndex, query] = targets[i]; + const request = objectStoreOrIndex.count(query); + request.onerror = onError; + request.onsuccess = (e) => onSuccess(e, index); + } + } + + delete(objectStoreName, key) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const request = objectStore.delete(key); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + }); + } + + bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + + const onGetKeys = (keys) => { + try { + if (typeof filterKeys === 'function') { + keys = filterKeys(keys); + } + this._bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject); + } catch (e) { + reject(e); + } + }; + + this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject); + }); + } + + static deleteDatabase(databaseName) { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(databaseName); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + request.onblocked = () => reject(new Error('Database deletion blocked')); + }); + } + + // Private + + _open(name, version, onUpgradeNeeded) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + + request.onupgradeneeded = (event) => { + try { + request.transaction.onerror = (e) => reject(e.target.error); + onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion); + } catch (e) { + reject(e); + } + }; + + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(request.result); + }); + } + + _upgrade(db, transaction, oldVersion, upgrades) { + for (const {version, stores} of upgrades) { + if (oldVersion >= version) { continue; } + + for (const [objectStoreName, {primaryKey, indices}] of Object.entries(stores)) { + const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames; + const objectStore = ( + this._listContains(existingObjectStoreNames, objectStoreName) ? + transaction.objectStore(objectStoreName) : + db.createObjectStore(objectStoreName, primaryKey) + ); + const existingIndexNames = objectStore.indexNames; + + for (const indexName of indices) { + if (this._listContains(existingIndexNames, indexName)) { continue; } + + objectStore.createIndex(indexName, indexName, {}); + } + } + } + } + + _listContains(list, value) { + for (let i = 0, ii = list.length; i < ii; ++i) { + if (list[i] === value) { return true; } + } + return false; + } + + _getAllFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAll(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.value); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _getAllKeysFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAllKeys(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openKeyCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.primaryKey); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject) { + const count = keys.length; + if (count === 0) { + resolve(); + return; + } + + let completedCount = 0; + const hasProgress = (typeof onProgress === 'function'); + + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + ++completedCount; + if (hasProgress) { + try { + onProgress(completedCount, count); + } catch (e) { + // NOP + } + } + if (completedCount >= count) { + resolve(); + } + }; + + for (const key of keys) { + const request = objectStore.delete(key); + request.onerror = onError; + request.onsuccess = onSuccess; + } + } +} diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js deleted file mode 100644 index 822174e2..00000000 --- a/ext/bg/js/handlebars.js +++ /dev/null @@ -1,172 +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 - * Handlebars - * jp - */ - -function handlebarsEscape(text) { - return Handlebars.Utils.escapeExpression(text); -} - -function handlebarsDumpObject(options) { - const dump = JSON.stringify(options.fn(this), null, 4); - return handlebarsEscape(dump); -} - -function handlebarsFurigana(options) { - const definition = options.fn(this); - const segs = jp.distributeFurigana(definition.expression, definition.reading); - - let result = ''; - for (const seg of segs) { - if (seg.furigana) { - result += `<ruby>${seg.text}<rt>${seg.furigana}</rt></ruby>`; - } else { - result += seg.text; - } - } - - return result; -} - -function handlebarsFuriganaPlain(options) { - const definition = options.fn(this); - const segs = jp.distributeFurigana(definition.expression, definition.reading); - - let result = ''; - for (const seg of segs) { - if (seg.furigana) { - result += ` ${seg.text}[${seg.furigana}]`; - } else { - result += seg.text; - } - } - - return result.trimLeft(); -} - -function handlebarsKanjiLinks(options) { - let result = ''; - for (const c of options.fn(this)) { - if (jp.isCodePointKanji(c.codePointAt(0))) { - result += `<a href="#" class="kanji-link">${c}</a>`; - } else { - result += c; - } - } - - return result; -} - -function handlebarsMultiLine(options) { - return options.fn(this).split('\n').join('<br>'); -} - -function handlebarsSanitizeCssClass(options) { - return options.fn(this).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_'); -} - -function handlebarsRegexReplace(...args) { - // Usage: - // {{#regexReplace regex string [flags]}}content{{/regexReplace}} - // regex: regular expression string - // string: string to replace - // flags: optional flags for regular expression - // e.g. "i" for case-insensitive, "g" for replace all - let value = args[args.length - 1].fn(this); - if (args.length >= 3) { - try { - const flags = args.length > 3 ? args[2] : 'g'; - const regex = new RegExp(args[0], flags); - value = value.replace(regex, args[1]); - } catch (e) { - return `${e}`; - } - } - return value; -} - -function handlebarsRegexMatch(...args) { - // Usage: - // {{#regexMatch regex [flags]}}content{{/regexMatch}} - // regex: regular expression string - // flags: optional flags for regular expression - // e.g. "i" for case-insensitive, "g" for match all - let value = args[args.length - 1].fn(this); - if (args.length >= 2) { - try { - const flags = args.length > 2 ? args[1] : ''; - const regex = new RegExp(args[0], flags); - const parts = []; - value.replace(regex, (g0) => parts.push(g0)); - value = parts.join(''); - } catch (e) { - return `${e}`; - } - } - 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; - Handlebars.registerHelper('dumpObject', handlebarsDumpObject); - Handlebars.registerHelper('furigana', handlebarsFurigana); - Handlebars.registerHelper('furiganaPlain', handlebarsFuriganaPlain); - Handlebars.registerHelper('kanjiLinks', handlebarsKanjiLinks); - Handlebars.registerHelper('multiLine', handlebarsMultiLine); - Handlebars.registerHelper('sanitizeCssClass', handlebarsSanitizeCssClass); - Handlebars.registerHelper('regexReplace', handlebarsRegexReplace); - Handlebars.registerHelper('regexMatch', handlebarsRegexMatch); - Handlebars.registerHelper('mergeTags', handlebarsMergeTags); - } -} - -function handlebarsRenderDynamic(template, data) { - handlebarsRegisterHelpers(); - const cache = handlebarsRenderDynamic._cache; - let instance = cache.get(template); - if (typeof instance === 'undefined') { - instance = Handlebars.compile(template); - cache.set(template, instance); - } - - return instance(data).trim(); -} -handlebarsRenderDynamic._cache = new Map(); diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 35fdde82..ccc56848 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -176,7 +176,9 @@ function profileOptionsCreateDefaults() { showPitchAccentDownstepNotation: true, showPitchAccentPositionNotation: true, showPitchAccentGraph: false, - showIframePopupsInRootFrame: false + showIframePopupsInRootFrame: false, + useSecurePopupFrameUrl: true, + usePopupShadowDom: true }, audio: { @@ -202,7 +204,8 @@ function profileOptionsCreateDefaults() { enablePopupSearch: false, enableOnPopupExpressions: false, enableOnSearchPage: true, - enableSearchTags: false + enableSearchTags: false, + layoutAwareScan: false }, translation: { diff --git a/ext/bg/js/request.js b/ext/bg/js/request.js index 957ac0f5..d1c6ed4e 100644 --- a/ext/bg/js/request.js +++ b/ext/bg/js/request.js @@ -16,28 +16,28 @@ */ -function requestText(url, action, params) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.overrideMimeType('text/plain'); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.addEventListener('error', () => reject(new Error('Failed to connect'))); - xhr.open(action, url); - if (params) { - xhr.send(JSON.stringify(params)); - } else { - xhr.send(); - } +async function requestText(url, method, data) { + const response = await fetch(url, { + method, + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: (data ? JSON.stringify(data) : void 0) }); + return await response.text(); } -async function requestJson(url, action, params) { - const responseText = await requestText(url, action, params); - try { - return JSON.parse(responseText); - } catch (e) { - const error = new Error(`Invalid response (${e.message || e})`); - error.data = {url, action, params, responseText}; - throw error; - } +async function requestJson(url, method, data) { + const response = await fetch(url, { + method, + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: (data ? JSON.stringify(data) : void 0) + }); + return await response.json(); } diff --git a/ext/bg/js/search-main.js b/ext/bg/js/search-main.js index 54fa549d..13bd8767 100644 --- a/ext/bg/js/search-main.js +++ b/ext/bg/js/search-main.js @@ -17,45 +17,17 @@ /* global * DisplaySearch - * apiForwardLogsToBackend - * apiOptionsGet - * dynamicLoader + * api */ -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-factory.js', - '/fg/js/frontend.js', - '/fg/js/content-script-main.js' - ]); -} - (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 options = await apiOptionsGet(optionsContext); - if (!options.scanning.enableOnSearchPage || optionsApplied) { return; } - - optionsApplied = true; - yomichan.off('optionsUpdated', applyOptions); - - window.frontendInitializationData = {depth: 1, proxy: false, isSearchPage: true}; - await injectSearchFrontend(); - }; - - yomichan.on('optionsUpdated', applyOptions); - - await applyOptions(); + try { + api.forwardLogsToBackend(); + await yomichan.prepare(); + + const displaySearch = new DisplaySearch(); + await displaySearch.prepare(); + } catch (e) { + yomichan.logError(e); + } })(); diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js index 9e7ff8aa..6989e157 100644 --- a/ext/bg/js/search-query-parser-generator.js +++ b/ext/bg/js/search-query-parser-generator.js @@ -17,7 +17,7 @@ /* global * TemplateHandler - * apiGetQueryParserTemplatesHtml + * api */ class QueryParserGenerator { @@ -26,7 +26,7 @@ class QueryParserGenerator { } async prepare() { - const html = await apiGetQueryParserTemplatesHtml(); + const html = await api.getQueryParserTemplatesHtml(); this._templateHandler = new TemplateHandler(html); } diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index e1e37d1c..86524b66 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -18,9 +18,7 @@ /* global * QueryParserGenerator * TextScanner - * apiModifySettings - * apiTermsFind - * apiTextParse + * api * docSentenceExtract */ @@ -44,6 +42,7 @@ class QueryParser { async prepare() { await this._queryParserGenerator.prepare(); + this._textScanner.prepare(); this._queryParser.addEventListener('click', this._onClick.bind(this)); } @@ -59,7 +58,7 @@ class QueryParser { this._setPreview(text); - this._parseResults = await apiTextParse(text, this._getOptionsContext()); + this._parseResults = await api.textParse(text, this._getOptionsContext()); this._refreshSelectedParser(); this._renderParserSelect(); @@ -77,15 +76,17 @@ class QueryParser { async _search(textSource, cause) { if (textSource === null) { return null; } - const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length); + const {length: scanLength, layoutAwareScan} = this._options.scanning; + const searchText = this._textScanner.getTextSourceContent(textSource, scanLength, layoutAwareScan); if (searchText.length === 0) { return null; } - const {definitions, length} = await apiTermsFind(searchText, {}, this._getOptionsContext()); + const {definitions, length} = await api.termsFind(searchText, {}, this._getOptionsContext()); if (definitions.length === 0) { return null; } - const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt); + const sentenceExtent = this._options.anki.sentenceExt; + const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); - textSource.setEndOffset(length); + textSource.setEndOffset(length, layoutAwareScan); this._setContent('terms', {definitions, context: { focus: false, @@ -99,7 +100,7 @@ class QueryParser { _onParserChange(e) { const value = e.target.value; - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'parsing.selectedParser', value, @@ -112,7 +113,7 @@ class QueryParser { if (this._parseResults.length > 0) { if (!this._getParseResult()) { const value = this._parseResults[0].id; - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'parsing.selectedParser', value, diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index 96e8a70b..e968e8cf 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -19,10 +19,11 @@ * ClipboardMonitor * DOM * Display + * Frontend + * PopupFactory * QueryParser - * apiClipboardGet - * apiModifySettings - * apiTermsFind + * api + * dynamicLoader * wanakana */ @@ -52,7 +53,7 @@ class DisplaySearch extends Display { this.introVisible = true; this.introAnimationTimer = null; - this.clipboardMonitor = new ClipboardMonitor({getClipboard: apiClipboardGet}); + this.clipboardMonitor = new ClipboardMonitor({getClipboard: api.clipboardGet.bind(api)}); this._onKeyDownIgnoreKeys = new Map([ ['ANY_MOD', new Set([ @@ -75,51 +76,49 @@ class DisplaySearch extends Display { } async prepare() { - try { - await super.prepare(); - await this.updateOptions(); - yomichan.on('optionsUpdated', () => this.updateOptions()); - await this.queryParser.prepare(); + await super.prepare(); + await this.updateOptions(); + yomichan.on('optionsUpdated', () => this.updateOptions()); + await this.queryParser.prepare(); + + const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); + + document.documentElement.dataset.searchMode = mode; - const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); + if (this.options.general.enableWanakana === true) { + this.wanakanaEnable.checked = true; + wanakana.bind(this.query); + } else { + this.wanakanaEnable.checked = false; + } - document.documentElement.dataset.searchMode = mode; + this.setQuery(query); + this.onSearchQueryUpdated(this.query.value, false); - if (this.options.general.enableWanakana === true) { - this.wanakanaEnable.checked = true; - wanakana.bind(this.query); + if (mode !== 'popup') { + if (this.options.general.enableClipboardMonitor === true) { + this.clipboardMonitorEnable.checked = true; + this.clipboardMonitor.start(); } else { - this.wanakanaEnable.checked = false; + this.clipboardMonitorEnable.checked = false; } + this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this)); + } - this.setQuery(query); - this.onSearchQueryUpdated(this.query.value, false); - - if (mode !== 'popup') { - if (this.options.general.enableClipboardMonitor === true) { - this.clipboardMonitorEnable.checked = true; - this.clipboardMonitor.start(); - } else { - this.clipboardMonitorEnable.checked = false; - } - this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this)); - } + chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); - chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); + this.search.addEventListener('click', this.onSearch.bind(this), false); + this.query.addEventListener('input', this.onSearchInput.bind(this), false); + this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this)); + window.addEventListener('popstate', this.onPopState.bind(this)); + window.addEventListener('copy', this.onCopy.bind(this)); + this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this)); - this.search.addEventListener('click', this.onSearch.bind(this), false); - this.query.addEventListener('input', this.onSearchInput.bind(this), false); - this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this)); - window.addEventListener('popstate', this.onPopState.bind(this)); - window.addEventListener('copy', this.onCopy.bind(this)); - this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this)); + this.updateSearchButton(); - this.updateSearchButton(); + await this._prepareNestedPopups(); - this._isPrepared = true; - } catch (e) { - this.onError(e); - } + this._isPrepared = true; } onError(error) { @@ -234,7 +233,7 @@ class DisplaySearch extends Display { this.setIntroVisible(!valid, animate); this.updateSearchButton(); if (valid) { - const {definitions} = await apiTermsFind(query, details, this.getOptionsContext()); + const {definitions} = await api.termsFind(query, details, this.getOptionsContext()); this.setContent('terms', {definitions, context: { focus: false, disableHistory: true, @@ -258,7 +257,7 @@ class DisplaySearch extends Display { } else { wanakana.unbind(this.query); } - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'general.enableWanakana', value, @@ -274,7 +273,7 @@ class DisplaySearch extends Display { (granted) => { if (granted) { this.clipboardMonitor.start(); - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'general.enableClipboardMonitor', value: true, @@ -288,7 +287,7 @@ class DisplaySearch extends Display { ); } else { this.clipboardMonitor.stop(); - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'general.enableClipboardMonitor', value: false, @@ -314,7 +313,14 @@ class DisplaySearch extends Display { } setQuery(query) { - const interpretedQuery = this.isWanakanaEnabled() ? wanakana.toKana(query) : query; + let interpretedQuery = query; + if (this.isWanakanaEnabled()) { + try { + interpretedQuery = wanakana.toKana(query); + } catch (e) { + // NOP + } + } this.query.value = interpretedQuery; this.queryParser.setText(interpretedQuery); } @@ -396,4 +402,53 @@ class DisplaySearch extends Display { document.title = `${text} - Yomichan Search`; } } + + async _prepareNestedPopups() { + let complete = false; + + const onOptionsUpdated = async () => { + const optionsContext = this.getOptionsContext(); + const options = await api.optionsGet(optionsContext); + if (!options.scanning.enableOnSearchPage || complete) { return; } + + complete = true; + yomichan.off('optionsUpdated', onOptionsUpdated); + + try { + await this._setupNestedPopups(); + } catch (e) { + yomichan.logError(e); + } + }; + + yomichan.on('optionsUpdated', onOptionsUpdated); + + await onOptionsUpdated(); + } + + async _setupNestedPopups() { + await dynamicLoader.loadScripts([ + '/mixed/js/text-scanner.js', + '/fg/js/frame-offset-forwarder.js', + '/fg/js/popup.js', + '/fg/js/popup-factory.js', + '/fg/js/frontend.js' + ]); + + const {frameId} = await api.frameInformationGet(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const frontend = new Frontend( + frameId, + popupFactory, + { + depth: 1, + proxy: false, + isSearchPage: true + } + ); + await frontend.prepare(); + } } diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js index d5b6e677..88d4fe04 100644 --- a/ext/bg/js/settings/anki-templates.js +++ b/ext/bg/js/settings/anki-templates.js @@ -17,144 +17,146 @@ /* global * AnkiNoteBuilder - * ankiGetFieldMarkers - * ankiGetFieldMarkersHtml - * apiGetDefaultAnkiFieldTemplates - * apiOptionsGet - * apiTemplateRender - * apiTermsFind - * getOptionsContext - * getOptionsMutable - * settingsSaveOptions + * api */ -function onAnkiFieldTemplatesReset(e) { - e.preventDefault(); - $('#field-template-reset-modal').modal('show'); -} +class AnkiTemplatesController { + constructor(settingsController, ankiController) { + this._settingsController = settingsController; + this._ankiController = ankiController; + this._cachedDefinitionValue = null; + this._cachedDefinitionText = null; + this._defaultFieldTemplates = null; + } -async function onAnkiFieldTemplatesResetConfirm(e) { - e.preventDefault(); + async prepare() { + this._defaultFieldTemplates = await api.getDefaultAnkiFieldTemplates(); - $('#field-template-reset-modal').modal('hide'); + const markers = new Set([ + ...this._ankiController.getFieldMarkers('terms'), + ...this._ankiController.getFieldMarkers('kanji') + ]); + const fragment = this._ankiController.getFieldMarkersHtml(markers); - const value = await apiGetDefaultAnkiFieldTemplates(); + const list = document.querySelector('#field-templates-list'); + list.appendChild(fragment); + for (const node of list.querySelectorAll('.marker-link')) { + node.addEventListener('click', this._onMarkerClicked.bind(this), false); + } - const element = document.querySelector('#field-templates'); - element.value = value; - element.dispatchEvent(new Event('change')); -} + document.querySelector('#field-templates').addEventListener('change', this._onChanged.bind(this), false); + document.querySelector('#field-template-render').addEventListener('click', this._onRender.bind(this), false); + document.querySelector('#field-templates-reset').addEventListener('click', this._onReset.bind(this), false); + document.querySelector('#field-templates-reset-confirm').addEventListener('click', this._onResetConfirm.bind(this), false); -function ankiTemplatesInitialize() { - const markers = new Set(ankiGetFieldMarkers('terms').concat(ankiGetFieldMarkers('kanji'))); - const fragment = ankiGetFieldMarkersHtml(markers); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); - const list = document.querySelector('#field-templates-list'); - list.appendChild(fragment); - for (const node of list.querySelectorAll('.marker-link')) { - node.addEventListener('click', onAnkiTemplateMarkerClicked, false); + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); } - $('#field-templates').on('change', onAnkiFieldTemplatesChanged); - $('#field-template-render').on('click', onAnkiTemplateRender); - $('#field-templates-reset').on('click', onAnkiFieldTemplatesReset); - $('#field-templates-reset-confirm').on('click', onAnkiFieldTemplatesResetConfirm); + // Private - ankiTemplatesUpdateValue(); -} + _onOptionsChanged({options}) { + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; } + document.querySelector('#field-templates').value = templates; -async function ankiTemplatesUpdateValue() { - const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); - let templates = options.anki.fieldTemplates; - if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); } - $('#field-templates').val(templates); + this._onValidateCompile(); + } - onAnkiTemplatesValidateCompile(); -} + _onReset(e) { + e.preventDefault(); + $('#field-template-reset-modal').modal('show'); + } -const ankiTemplatesValidateGetDefinition = (() => { - let cachedValue = null; - let cachedText = null; + _onResetConfirm(e) { + e.preventDefault(); - return async (text, optionsContext) => { - if (cachedText !== text) { - const {definitions} = await apiTermsFind(text, {}, optionsContext); - if (definitions.length === 0) { return null; } + $('#field-template-reset-modal').modal('hide'); - cachedValue = definitions[0]; - cachedText = text; - } - return cachedValue; - }; -})(); - -async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, invalidateInput) { - const text = document.querySelector('#field-templates-preview-text').value || ''; - const exceptions = []; - let result = `No definition found for ${text}`; - try { - const optionsContext = getOptionsContext(); - const definition = await ankiTemplatesValidateGetDefinition(text, optionsContext); - if (definition !== null) { - const options = await apiOptionsGet(optionsContext); - const context = { - document: { - title: document.title - } - }; - let templates = options.anki.fieldTemplates; - if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); } - const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: apiTemplateRender}); - result = await ankiNoteBuilder.formatField(field, definition, mode, context, options, templates, exceptions); + const value = this._defaultFieldTemplates; + + const element = document.querySelector('#field-templates'); + element.value = value; + element.dispatchEvent(new Event('change')); + } + + async _onChanged(e) { + // Get value + let templates = e.currentTarget.value; + if (templates === this._defaultFieldTemplates) { + // Default + templates = null; } - } catch (e) { - exceptions.push(e); + + // Overwrite + await this._settingsController.setProfileSetting('anki.fieldTemplates', templates); + + // Compile + this._onValidateCompile(); } - const hasException = exceptions.length > 0; - infoNode.hidden = !(showSuccessResult || hasException); - infoNode.textContent = hasException ? exceptions.map((e) => `${e}`).join('\n') : (showSuccessResult ? result : ''); - infoNode.classList.toggle('text-danger', hasException); - if (invalidateInput) { - const input = document.querySelector('#field-templates'); - input.classList.toggle('is-invalid', hasException); + _onValidateCompile() { + const infoNode = document.querySelector('#field-template-compile-result'); + this._validate(infoNode, '{expression}', 'term-kanji', false, true); } -} -async function onAnkiFieldTemplatesChanged(e) { - // Get value - let templates = e.currentTarget.value; - if (templates === await apiGetDefaultAnkiFieldTemplates()) { - // Default - templates = null; + _onMarkerClicked(e) { + e.preventDefault(); + document.querySelector('#field-template-render-text').value = `{${e.target.textContent}}`; } - // Overwrite - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - options.anki.fieldTemplates = templates; - await settingsSaveOptions(); + _onRender(e) { + e.preventDefault(); - // Compile - onAnkiTemplatesValidateCompile(); -} + const field = document.querySelector('#field-template-render-text').value; + const infoNode = document.querySelector('#field-template-render-result'); + infoNode.hidden = true; + this._validate(infoNode, field, 'term-kanji', true, false); + } -function onAnkiTemplatesValidateCompile() { - const infoNode = document.querySelector('#field-template-compile-result'); - ankiTemplatesValidate(infoNode, '{expression}', 'term-kanji', false, true); -} + async _getDefinition(text, optionsContext) { + if (this._cachedDefinitionText !== text) { + const {definitions} = await api.termsFind(text, {}, optionsContext); + if (definitions.length === 0) { return null; } -function onAnkiTemplateMarkerClicked(e) { - e.preventDefault(); - document.querySelector('#field-template-render-text').value = `{${e.target.textContent}}`; -} + this._cachedDefinitionValue = definitions[0]; + this._cachedDefinitionText = text; + } + return this._cachedDefinitionValue; + } -function onAnkiTemplateRender(e) { - e.preventDefault(); + async _validate(infoNode, field, mode, showSuccessResult, invalidateInput) { + const text = document.querySelector('#field-templates-preview-text').value || ''; + const exceptions = []; + let result = `No definition found for ${text}`; + try { + const optionsContext = this._settingsController.getOptionsContext(); + const definition = await this._getDefinition(text, optionsContext); + if (definition !== null) { + const options = await this._settingsController.getOptions(); + const context = { + document: { + title: document.title + } + }; + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; } + const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: api.templateRender.bind(api)}); + result = await ankiNoteBuilder.formatField(field, definition, mode, context, options, templates, exceptions); + } + } catch (e) { + exceptions.push(e); + } - const field = document.querySelector('#field-template-render-text').value; - const infoNode = document.querySelector('#field-template-render-result'); - infoNode.hidden = true; - ankiTemplatesValidate(infoNode, field, 'term-kanji', true, false); + const hasException = exceptions.length > 0; + infoNode.hidden = !(showSuccessResult || hasException); + infoNode.textContent = hasException ? exceptions.map((e) => `${e}`).join('\n') : (showSuccessResult ? result : ''); + infoNode.classList.toggle('text-danger', hasException); + if (invalidateInput) { + const input = document.querySelector('#field-templates'); + input.classList.toggle('is-invalid', hasException); + } + } } diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index ff1277ed..51dabba4 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -16,278 +16,282 @@ */ /* global - * apiGetAnkiDeckNames - * apiGetAnkiModelFieldNames - * apiGetAnkiModelNames - * getOptionsContext - * getOptionsMutable - * onFormOptionsChanged - * settingsSaveOptions - * utilBackgroundIsolate + * api */ -// Private - -let _ankiDataPopulated = false; - - -function _ankiSpinnerShow(show) { - const spinner = $('#anki-spinner'); - if (show) { - spinner.show(); - } else { - spinner.hide(); +class AnkiController { + constructor(settingsController) { + this._settingsController = settingsController; } -} -function _ankiSetError(error) { - const node = document.querySelector('#anki-error'); - const node2 = document.querySelector('#anki-invalid-response-error'); - if (error) { - const errorString = `${error}`; - if (node !== null) { - node.hidden = false; - node.textContent = errorString; - _ankiSetErrorData(node, error); + async prepare() { + for (const element of document.querySelectorAll('#anki-fields-container input,#anki-fields-container select')) { + element.addEventListener('change', this._onFieldsChanged.bind(this), false); } - if (node2 !== null) { - node2.hidden = (errorString.indexOf('Invalid response') < 0); - } - } else { - if (node !== null) { - node.hidden = true; - node.textContent = ''; + for (const element of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) { + element.addEventListener('change', this._onModelChanged.bind(this), false); } - if (node2 !== null) { - node2.hidden = true; - } - } -} + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); -function _ankiSetErrorData(node, error) { - const data = error.data; - let message = ''; - if (typeof data !== 'undefined') { - message += `${JSON.stringify(data, null, 4)}\n\n`; + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); } - message += `${error.stack}`.trimRight(); - const button = document.createElement('a'); - button.className = 'error-data-show-button'; + getFieldMarkers(type) { + switch (type) { + case 'terms': + return [ + 'audio', + 'cloze-body', + 'cloze-prefix', + 'cloze-suffix', + 'dictionary', + 'document-title', + 'expression', + 'furigana', + 'furigana-plain', + 'glossary', + 'glossary-brief', + 'reading', + 'screenshot', + 'sentence', + 'tags', + 'url' + ]; + case 'kanji': + return [ + 'character', + 'dictionary', + 'document-title', + 'glossary', + 'kunyomi', + 'onyomi', + 'screenshot', + 'sentence', + 'tags', + 'url' + ]; + default: + return []; + } + } - const content = document.createElement('div'); - content.className = 'error-data-container'; - content.textContent = message; - content.hidden = true; + getFieldMarkersHtml(markers) { + const template = document.querySelector('#anki-field-marker-template').content; + const fragment = document.createDocumentFragment(); + for (const marker of markers) { + const markerNode = document.importNode(template, true).firstChild; + markerNode.querySelector('.marker-link').textContent = marker; + fragment.appendChild(markerNode); + } + return fragment; + } - button.addEventListener('click', () => content.hidden = !content.hidden, false); + // Private - node.appendChild(button); - node.appendChild(content); -} + async _onOptionsChanged({options}) { + if (!options.anki.enable) { + return; + } -function _ankiSetDropdownOptions(dropdown, optionValues) { - const fragment = document.createDocumentFragment(); - for (const optionValue of optionValues) { - const option = document.createElement('option'); - option.value = optionValue; - option.textContent = optionValue; - fragment.appendChild(option); + await this._deckAndModelPopulate(options); + await Promise.all([ + this._populateFields('terms', options.anki.terms.fields), + this._populateFields('kanji', options.anki.kanji.fields) + ]); } - dropdown.textContent = ''; - dropdown.appendChild(fragment); -} -async function _ankiDeckAndModelPopulate(options) { - const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'}; - const kanjiDeck = {value: options.anki.kanji.deck, selector: '#anki-kanji-deck'}; - const termsModel = {value: options.anki.terms.model, selector: '#anki-terms-model'}; - const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'}; - try { - _ankiSpinnerShow(true); - const [deckNames, modelNames] = await Promise.all([apiGetAnkiDeckNames(), apiGetAnkiModelNames()]); - deckNames.sort(); - modelNames.sort(); - termsDeck.values = deckNames; - kanjiDeck.values = deckNames; - termsModel.values = modelNames; - kanjiModel.values = modelNames; - _ankiSetError(null); - } catch (error) { - _ankiSetError(error); - } finally { - _ankiSpinnerShow(false); + _fieldsToDict(elements) { + const result = {}; + for (const element of elements) { + result[element.dataset.field] = element.value; + } + return result; } - for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) { - const node = document.querySelector(selector); - _ankiSetDropdownOptions(node, Array.isArray(values) ? values : [value]); - node.value = value; + _spinnerShow(show) { + const spinner = document.querySelector('#anki-spinner'); + spinner.hidden = !show; } -} -function _ankiCreateFieldTemplate(name, value, markers) { - const template = document.querySelector('#anki-field-template').content; - const content = document.importNode(template, true).firstChild; + _setError(error) { + const node = document.querySelector('#anki-error'); + const node2 = document.querySelector('#anki-invalid-response-error'); + if (error) { + const errorString = `${error}`; + if (node !== null) { + node.hidden = false; + node.textContent = errorString; + this._setErrorData(node, error); + } + + if (node2 !== null) { + node2.hidden = (errorString.indexOf('Invalid response') < 0); + } + } else { + if (node !== null) { + node.hidden = true; + node.textContent = ''; + } + + if (node2 !== null) { + node2.hidden = true; + } + } + } - content.querySelector('.anki-field-name').textContent = name; + _setErrorData(node, error) { + const data = error.data; + let message = ''; + if (typeof data !== 'undefined') { + message += `${JSON.stringify(data, null, 4)}\n\n`; + } + message += `${error.stack}`.trimRight(); - const field = content.querySelector('.anki-field-value'); - field.dataset.field = name; - field.value = value; + const button = document.createElement('a'); + button.className = 'error-data-show-button'; - content.querySelector('.anki-field-marker-list').appendChild(ankiGetFieldMarkersHtml(markers)); + const content = document.createElement('div'); + content.className = 'error-data-container'; + content.textContent = message; + content.hidden = true; - return content; -} + button.addEventListener('click', () => content.hidden = !content.hidden, false); -async function _ankiFieldsPopulate(tabId, options) { - const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`); - const container = tab.querySelector('tbody'); - const markers = ankiGetFieldMarkers(tabId); - - const fragment = document.createDocumentFragment(); - const fields = options.anki[tabId].fields; - for (const name of Object.keys(fields)) { - const value = fields[name]; - const html = _ankiCreateFieldTemplate(name, value, markers); - fragment.appendChild(html); + node.appendChild(button); + node.appendChild(content); } - container.textContent = ''; - container.appendChild(fragment); - - for (const node of container.querySelectorAll('.anki-field-value')) { - node.addEventListener('change', onFormOptionsChanged, false); - } - for (const node of container.querySelectorAll('.marker-link')) { - node.addEventListener('click', _onAnkiMarkerClicked, false); + _setDropdownOptions(dropdown, optionValues) { + const fragment = document.createDocumentFragment(); + for (const optionValue of optionValues) { + const option = document.createElement('option'); + option.value = optionValue; + option.textContent = optionValue; + fragment.appendChild(option); + } + dropdown.textContent = ''; + dropdown.appendChild(fragment); } -} -function _onAnkiMarkerClicked(e) { - e.preventDefault(); - const link = e.currentTarget; - const input = $(link).closest('.input-group').find('.anki-field-value')[0]; - input.value = `{${link.textContent}}`; - input.dispatchEvent(new Event('change')); -} + async _deckAndModelPopulate(options) { + const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'}; + const kanjiDeck = {value: options.anki.kanji.deck, selector: '#anki-kanji-deck'}; + const termsModel = {value: options.anki.terms.model, selector: '#anki-terms-model'}; + const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'}; + try { + this._spinnerShow(true); + const [deckNames, modelNames] = await Promise.all([api.getAnkiDeckNames(), api.getAnkiModelNames()]); + deckNames.sort(); + modelNames.sort(); + termsDeck.values = deckNames; + kanjiDeck.values = deckNames; + termsModel.values = modelNames; + kanjiModel.values = modelNames; + this._setError(null); + } catch (error) { + this._setError(error); + } finally { + this._spinnerShow(false); + } -async function _onAnkiModelChanged(e) { - const node = e.currentTarget; - let fieldNames; - try { - const modelName = node.value; - fieldNames = await apiGetAnkiModelFieldNames(modelName); - _ankiSetError(null); - } catch (error) { - _ankiSetError(error); - return; - } finally { - _ankiSpinnerShow(false); + for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) { + const node = document.querySelector(selector); + this._setDropdownOptions(node, Array.isArray(values) ? values : [value]); + node.value = value; + } } - const tabId = node.dataset.ankiCardType; - if (tabId !== 'terms' && tabId !== 'kanji') { return; } - - const fields = {}; - for (const name of fieldNames) { - fields[name] = ''; - } + _createFieldTemplate(name, value, markers) { + const template = document.querySelector('#anki-field-template').content; + const content = document.importNode(template, true).firstChild; - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - options.anki[tabId].fields = utilBackgroundIsolate(fields); - await settingsSaveOptions(); + content.querySelector('.anki-field-name').textContent = name; - await _ankiFieldsPopulate(tabId, options); -} + const field = content.querySelector('.anki-field-value'); + field.dataset.field = name; + field.value = value; + content.querySelector('.anki-field-marker-list').appendChild(this.getFieldMarkersHtml(markers)); -// Public + return content; + } -function ankiErrorShown() { - const node = document.querySelector('#anki-error'); - return node && !node.hidden; -} + async _populateFields(tabId, fields) { + const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`); + const container = tab.querySelector('tbody'); + const markers = this.getFieldMarkers(tabId); -function ankiFieldsToDict(elements) { - const result = {}; - for (const element of elements) { - result[element.dataset.field] = element.value; - } - return result; -} + const fragment = document.createDocumentFragment(); + for (const [name, value] of Object.entries(fields)) { + const html = this._createFieldTemplate(name, value, markers); + fragment.appendChild(html); + } + container.textContent = ''; + container.appendChild(fragment); -function ankiGetFieldMarkersHtml(markers) { - const template = document.querySelector('#anki-field-marker-template').content; - const fragment = document.createDocumentFragment(); - for (const marker of markers) { - const markerNode = document.importNode(template, true).firstChild; - markerNode.querySelector('.marker-link').textContent = marker; - fragment.appendChild(markerNode); + for (const node of container.querySelectorAll('.anki-field-value')) { + node.addEventListener('change', this._onFieldsChanged.bind(this), false); + } + for (const node of container.querySelectorAll('.marker-link')) { + node.addEventListener('click', this._onMarkerClicked.bind(this), false); + } } - return fragment; -} -function ankiGetFieldMarkers(type) { - switch (type) { - case 'terms': - return [ - 'audio', - 'cloze-body', - 'cloze-prefix', - 'cloze-suffix', - 'dictionary', - 'document-title', - 'expression', - 'furigana', - 'furigana-plain', - 'glossary', - 'glossary-brief', - 'reading', - 'screenshot', - 'sentence', - 'tags', - 'url' - ]; - case 'kanji': - return [ - 'character', - 'dictionary', - 'document-title', - 'glossary', - 'kunyomi', - 'onyomi', - 'screenshot', - 'sentence', - 'tags', - 'url' - ]; - default: - return []; + _onMarkerClicked(e) { + e.preventDefault(); + const link = e.currentTarget; + const input = link.closest('.input-group').querySelector('.anki-field-value'); + input.value = `{${link.textContent}}`; + input.dispatchEvent(new Event('change')); } -} + async _onModelChanged(e) { + const node = e.currentTarget; + let fieldNames; + try { + const modelName = node.value; + fieldNames = await api.getAnkiModelFieldNames(modelName); + this._setError(null); + } catch (error) { + this._setError(error); + return; + } finally { + this._spinnerShow(false); + } -function ankiInitialize() { - for (const node of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) { - node.addEventListener('change', _onAnkiModelChanged, false); - } -} + const tabId = node.dataset.ankiCardType; + if (tabId !== 'terms' && tabId !== 'kanji') { return; } -async function onAnkiOptionsChanged(options) { - if (!options.anki.enable) { - _ankiDataPopulated = false; - return; - } + const fields = {}; + for (const name of fieldNames) { + fields[name] = ''; + } - if (_ankiDataPopulated) { return; } + await this._settingsController.setProfileSetting(`anki["${tabId}"].fields`, fields); + await this._populateFields(tabId, fields); + } - await _ankiDeckAndModelPopulate(options); - _ankiDataPopulated = true; - await Promise.all([_ankiFieldsPopulate('terms', options), _ankiFieldsPopulate('kanji', options)]); + async _onFieldsChanged() { + const termsDeck = document.querySelector('#anki-terms-deck').value; + const termsModel = document.querySelector('#anki-terms-model').value; + const termsFields = this._fieldsToDict(document.querySelectorAll('#terms .anki-field-value')); + const kanjiDeck = document.querySelector('#anki-kanji-deck').value; + const kanjiModel = document.querySelector('#anki-kanji-model').value; + const kanjiFields = this._fieldsToDict(document.querySelectorAll('#kanji .anki-field-value')); + + const targets = [ + {action: 'set', path: 'anki.terms.deck', value: termsDeck}, + {action: 'set', path: 'anki.terms.model', value: termsModel}, + {action: 'set', path: 'anki.terms.fields', value: termsFields}, + {action: 'set', path: 'anki.kanji.deck', value: kanjiDeck}, + {action: 'set', path: 'anki.kanji.model', value: kanjiModel}, + {action: 'set', path: 'anki.kanji.fields', value: kanjiFields} + ]; + + await this._settingsController.modifyProfileSettings(targets); + } } diff --git a/ext/bg/js/settings/audio-ui.js b/ext/bg/js/settings/audio-ui.js deleted file mode 100644 index 73c64227..00000000 --- a/ext/bg/js/settings/audio-ui.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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/>. - */ - -class AudioSourceUI { - static instantiateTemplate(templateSelector) { - const template = document.querySelector(templateSelector); - const content = document.importNode(template.content, true); - return content.firstChild; - } -} - -AudioSourceUI.Container = class Container { - constructor(audioSources, container, addButton) { - this.audioSources = audioSources; - this.container = container; - this.addButton = addButton; - this.children = []; - - this.container.textContent = ''; - - for (const audioSource of toIterable(audioSources)) { - this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length)); - } - - this._clickListener = this.onAddAudioSource.bind(this); - this.addButton.addEventListener('click', this._clickListener, false); - } - - cleanup() { - for (const child of this.children) { - child.cleanup(); - } - - this.addButton.removeEventListener('click', this._clickListener, false); - this.container.textContent = ''; - this._clickListener = null; - } - - save() { - // Override - } - - remove(child) { - const index = this.children.indexOf(child); - if (index < 0) { - return; - } - - child.cleanup(); - this.children.splice(index, 1); - this.audioSources.splice(index, 1); - - for (let i = index; i < this.children.length; ++i) { - this.children[i].index = i; - } - } - - onAddAudioSource() { - const audioSource = this.getUnusedAudioSource(); - this.audioSources.push(audioSource); - this.save(); - this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length)); - } - - getUnusedAudioSource() { - const audioSourcesAvailable = [ - 'jpod101', - 'jpod101-alternate', - 'jisho', - 'custom' - ]; - for (const source of audioSourcesAvailable) { - if (this.audioSources.indexOf(source) < 0) { - return source; - } - } - return audioSourcesAvailable[0]; - } -}; - -AudioSourceUI.AudioSource = class AudioSource { - constructor(parent, audioSource, index) { - this.parent = parent; - this.audioSource = audioSource; - this.index = index; - - this.container = AudioSourceUI.instantiateTemplate('#audio-source-template'); - this.select = this.container.querySelector('.audio-source-select'); - this.removeButton = this.container.querySelector('.audio-source-remove'); - - this.select.value = audioSource; - - this._selectChangeListener = this.onSelectChanged.bind(this); - this._removeClickListener = this.onRemoveClicked.bind(this); - - this.select.addEventListener('change', this._selectChangeListener, false); - this.removeButton.addEventListener('click', this._removeClickListener, false); - - parent.container.appendChild(this.container); - } - - cleanup() { - this.select.removeEventListener('change', this._selectChangeListener, false); - this.removeButton.removeEventListener('click', this._removeClickListener, false); - - if (this.container.parentNode !== null) { - this.container.parentNode.removeChild(this.container); - } - } - - save() { - this.parent.save(); - } - - onSelectChanged() { - this.audioSource = this.select.value; - this.parent.audioSources[this.index] = this.audioSource; - this.save(); - } - - onRemoveClicked() { - this.parent.remove(this); - this.save(); - } -}; diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index ac2d82f3..d389acb5 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -16,110 +16,219 @@ */ /* global - * AudioSourceUI * AudioSystem - * getOptionsContext - * getOptionsMutable - * settingsSaveOptions */ -let audioSourceUI = null; -let audioSystem = null; - -async function audioSettingsInitialize() { - audioSystem = new AudioSystem({ - audioUriBuilder: null, - useCache: true - }); - - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - audioSourceUI = new AudioSourceUI.Container( - options.audio.sources, - document.querySelector('.audio-source-list'), - document.querySelector('.audio-source-add') - ); - audioSourceUI.save = settingsSaveOptions; - - textToSpeechInitialize(); -} +class AudioController { + constructor(settingsController) { + this._settingsController = settingsController; + this._audioSystem = null; + this._audioSourceContainer = null; + this._audioSourceAddButton = null; + this._audioSourceEntries = []; + } -function textToSpeechInitialize() { - if (typeof speechSynthesis === 'undefined') { return; } + async prepare() { + this._audioSystem = new AudioSystem({ + audioUriBuilder: null, + useCache: true + }); - speechSynthesis.addEventListener('voiceschanged', updateTextToSpeechVoices, false); - updateTextToSpeechVoices(); + this._audioSourceContainer = document.querySelector('.audio-source-list'); + this._audioSourceAddButton = document.querySelector('.audio-source-add'); + this._audioSourceContainer.textContent = ''; - document.querySelector('#text-to-speech-voice').addEventListener('change', onTextToSpeechVoiceChange, false); - document.querySelector('#text-to-speech-voice-test').addEventListener('click', textToSpeechTest, false); -} + this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false); + + this._prepareTextToSpeech(); + + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); -function updateTextToSpeechVoices() { - const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index})); - voices.sort(textToSpeechVoiceCompare); + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private - document.querySelector('#text-to-speech-voice-container').hidden = (voices.length === 0); + _onOptionsChanged({options}) { + for (let i = this._audioSourceEntries.length - 1; i >= 0; --i) { + this._cleanupAudioSourceEntry(i); + } - const fragment = document.createDocumentFragment(); + for (const audioSource of options.audio.sources) { + this._createAudioSourceEntry(audioSource); + } + } - let option = document.createElement('option'); - option.value = ''; - option.textContent = 'None'; - fragment.appendChild(option); + _prepareTextToSpeech() { + if (typeof speechSynthesis === 'undefined') { return; } - for (const {voice} of voices) { - option = document.createElement('option'); - option.value = voice.voiceURI; - option.textContent = `${voice.name} (${voice.lang})`; + speechSynthesis.addEventListener('voiceschanged', this._updateTextToSpeechVoices.bind(this), false); + this._updateTextToSpeechVoices(); + + document.querySelector('#text-to-speech-voice').addEventListener('change', this._onTextToSpeechVoiceChange.bind(this), false); + document.querySelector('#text-to-speech-voice-test').addEventListener('click', this._testTextToSpeech.bind(this), false); + } + + _updateTextToSpeechVoices() { + const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index})); + voices.sort(this._textToSpeechVoiceCompare.bind(this)); + + document.querySelector('#text-to-speech-voice-container').hidden = (voices.length === 0); + + const fragment = document.createDocumentFragment(); + + let option = document.createElement('option'); + option.value = ''; + option.textContent = 'None'; fragment.appendChild(option); + + for (const {voice} of voices) { + option = document.createElement('option'); + option.value = voice.voiceURI; + option.textContent = `${voice.name} (${voice.lang})`; + fragment.appendChild(option); + } + + const select = document.querySelector('#text-to-speech-voice'); + select.textContent = ''; + select.appendChild(fragment); + select.value = select.dataset.value; } - const select = document.querySelector('#text-to-speech-voice'); - select.textContent = ''; - select.appendChild(fragment); - select.value = select.dataset.value; -} + _textToSpeechVoiceCompare(a, b) { + const aIsJapanese = this._languageTagIsJapanese(a.voice.lang); + const bIsJapanese = this._languageTagIsJapanese(b.voice.lang); + if (aIsJapanese) { + if (!bIsJapanese) { return -1; } + } else { + if (bIsJapanese) { return 1; } + } + + const aIsDefault = a.voice.default; + const bIsDefault = b.voice.default; + if (aIsDefault) { + if (!bIsDefault) { return -1; } + } else { + if (bIsDefault) { return 1; } + } + + return a.index - b.index; + } -function languageTagIsJapanese(languageTag) { - return ( - languageTag.startsWith('ja-') || - languageTag.startsWith('jpn-') - ); -} + _languageTagIsJapanese(languageTag) { + return ( + languageTag.startsWith('ja_') || + languageTag.startsWith('ja-') || + languageTag.startsWith('jpn-') + ); + } -function textToSpeechVoiceCompare(a, b) { - const aIsJapanese = languageTagIsJapanese(a.voice.lang); - const bIsJapanese = languageTagIsJapanese(b.voice.lang); - if (aIsJapanese) { - if (!bIsJapanese) { return -1; } - } else { - if (bIsJapanese) { return 1; } + _testTextToSpeech() { + try { + const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || ''; + const voiceUri = document.querySelector('#text-to-speech-voice').value; + + const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri); + audio.volume = 1.0; + audio.play(); + } catch (e) { + // NOP + } } - const aIsDefault = a.voice.default; - const bIsDefault = b.voice.default; - if (aIsDefault) { - if (!bIsDefault) { return -1; } - } else { - if (bIsDefault) { return 1; } + _instantiateTemplate(templateSelector) { + const template = document.querySelector(templateSelector); + const content = document.importNode(template.content, true); + return content.firstChild; } - return a.index - b.index; -} + _getUnusedAudioSource() { + const audioSourcesAvailable = [ + 'jpod101', + 'jpod101-alternate', + 'jisho', + 'custom' + ]; + for (const source of audioSourcesAvailable) { + if (!this._audioSourceEntries.some((metadata) => metadata.value === source)) { + return source; + } + } + return audioSourcesAvailable[0]; + } + + _createAudioSourceEntry(value) { + const eventListeners = new EventListenerCollection(); + const container = this._instantiateTemplate('#audio-source-template'); + const select = container.querySelector('.audio-source-select'); + const removeButton = container.querySelector('.audio-source-remove'); + + select.value = value; + + const entry = { + container, + eventListeners, + value + }; -function textToSpeechTest() { - try { - const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || ''; - const voiceUri = document.querySelector('#text-to-speech-voice').value; + eventListeners.addEventListener(select, 'change', this._onAudioSourceSelectChange.bind(this, entry), false); + eventListeners.addEventListener(removeButton, 'click', this._onAudioSourceRemoveClicked.bind(this, entry), false); - const audio = audioSystem.createTextToSpeechAudio(text, voiceUri); - audio.volume = 1.0; - audio.play(); - } catch (e) { - // NOP + this._audioSourceContainer.appendChild(container); + this._audioSourceEntries.push(entry); + } + + async _removeAudioSourceEntry(entry) { + const index = this._audioSourceEntries.indexOf(entry); + if (index < 0) { return; } + + this._cleanupAudioSourceEntry(index); + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'audio.sources', + start: index, + deleteCount: 1, + items: [] + }]); + } + + _cleanupAudioSourceEntry(index) { + const {container, eventListeners} = this._audioSourceEntries[index]; + if (container.parentNode !== null) { + container.parentNode.removeChild(container); + } + eventListeners.removeAllEventListeners(); + this._audioSourceEntries.splice(index, 1); + } + + _onTextToSpeechVoiceChange(e) { + e.currentTarget.dataset.value = e.currentTarget.value; + } + + async _onAddAudioSource() { + const audioSource = this._getUnusedAudioSource(); + const index = this._audioSourceEntries.length; + this._createAudioSourceEntry(audioSource); + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'audio.sources', + start: index, + deleteCount: 0, + items: [audioSource] + }]); } -} -function onTextToSpeechVoiceChange(e) { - e.currentTarget.dataset.value = e.currentTarget.value; + async _onAudioSourceSelectChange(entry, event) { + const index = this._audioSourceEntries.indexOf(entry); + if (index < 0) { return; } + + const value = event.currentTarget.value; + entry.value = value; + await this._settingsController.setProfileSetting(`audio.sources[${index}]`, value); + } + + async _onAudioSourceRemoveClicked(entry) { + await this._removeAudioSourceEntry(entry); + } } diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index faf4e592..13f90886 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -16,363 +16,362 @@ */ /* global - * apiGetDefaultAnkiFieldTemplates - * apiGetEnvironmentInfo - * apiOptionsGetFull + * api * optionsGetDefault * optionsUpdateVersion - * utilBackend - * utilBackgroundIsolate - * utilIsolate - * utilReadFileArrayBuffer */ -// Exporting - -let _settingsExportToken = null; -let _settingsExportRevoke = null; -const SETTINGS_EXPORT_CURRENT_VERSION = 0; - -function _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) { - const values = [ - date.getUTCFullYear().toString(), - dateSeparator, - (date.getUTCMonth() + 1).toString().padStart(2, '0'), - dateSeparator, - date.getUTCDate().toString().padStart(2, '0'), - dateTimeSeparator, - date.getUTCHours().toString().padStart(2, '0'), - timeSeparator, - date.getUTCMinutes().toString().padStart(2, '0'), - timeSeparator, - date.getUTCSeconds().toString().padStart(2, '0') - ]; - return values.slice(0, resolution * 2 - 1).join(''); -} +class SettingsBackup { + constructor(settingsController) { + this._settingsController = settingsController; + this._settingsExportToken = null; + this._settingsExportRevoke = null; + this._currentVersion = 0; + } -async function _getSettingsExportData(date) { - const optionsFull = await apiOptionsGetFull(); - const environment = await apiGetEnvironmentInfo(); - const fieldTemplatesDefault = await apiGetDefaultAnkiFieldTemplates(); + prepare() { + document.querySelector('#settings-export').addEventListener('click', this._onSettingsExportClick.bind(this), false); + document.querySelector('#settings-import').addEventListener('click', this._onSettingsImportClick.bind(this), false); + document.querySelector('#settings-import-file').addEventListener('change', this._onSettingsImportFileChange.bind(this), false); + document.querySelector('#settings-reset').addEventListener('click', this._onSettingsResetClick.bind(this), false); + document.querySelector('#settings-reset-modal-confirm').addEventListener('click', this._onSettingsResetConfirmClick.bind(this), false); + } - // Format options - for (const {options} of optionsFull.profiles) { - if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) { - delete options.anki.fieldTemplates; // Default - } + // Private + + _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) { + const values = [ + date.getUTCFullYear().toString(), + dateSeparator, + (date.getUTCMonth() + 1).toString().padStart(2, '0'), + dateSeparator, + date.getUTCDate().toString().padStart(2, '0'), + dateTimeSeparator, + date.getUTCHours().toString().padStart(2, '0'), + timeSeparator, + date.getUTCMinutes().toString().padStart(2, '0'), + timeSeparator, + date.getUTCSeconds().toString().padStart(2, '0') + ]; + return values.slice(0, resolution * 2 - 1).join(''); } - const data = { - version: SETTINGS_EXPORT_CURRENT_VERSION, - date: _getSettingsExportDateString(date, '-', ' ', ':', 6), - url: chrome.runtime.getURL('/'), - manifest: chrome.runtime.getManifest(), - environment, - userAgent: navigator.userAgent, - options: optionsFull - }; - - return data; -} + async _getSettingsExportData(date) { + const optionsFull = await this._settingsController.getOptionsFull(); + const environment = await api.getEnvironmentInfo(); + const fieldTemplatesDefault = await api.getDefaultAnkiFieldTemplates(); -function _saveBlob(blob, fileName) { - if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') { - if (navigator.msSaveBlob(blob)) { - return; + // Format options + for (const {options} of optionsFull.profiles) { + if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) { + delete options.anki.fieldTemplates; // Default + } } - } - const blobUrl = URL.createObjectURL(blob); + const data = { + version: this._currentVersion, + date: this._getSettingsExportDateString(date, '-', ' ', ':', 6), + url: chrome.runtime.getURL('/'), + manifest: chrome.runtime.getManifest(), + environment, + userAgent: navigator.userAgent, + options: optionsFull + }; - const a = document.createElement('a'); - a.href = blobUrl; - a.download = fileName; - a.rel = 'noopener'; - a.target = '_blank'; + return data; + } - const revoke = () => { - URL.revokeObjectURL(blobUrl); - a.href = ''; - _settingsExportRevoke = null; - }; - _settingsExportRevoke = revoke; + _saveBlob(blob, fileName) { + if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') { + if (navigator.msSaveBlob(blob)) { + return; + } + } - a.dispatchEvent(new MouseEvent('click')); - setTimeout(revoke, 60000); -} + const blobUrl = URL.createObjectURL(blob); -async function _onSettingsExportClick() { - if (_settingsExportRevoke !== null) { - _settingsExportRevoke(); - _settingsExportRevoke = null; - } + const a = document.createElement('a'); + a.href = blobUrl; + a.download = fileName; + a.rel = 'noopener'; + a.target = '_blank'; - const date = new Date(Date.now()); + const revoke = () => { + URL.revokeObjectURL(blobUrl); + a.href = ''; + this._settingsExportRevoke = null; + }; + this._settingsExportRevoke = revoke; - const token = {}; - _settingsExportToken = token; - const data = await _getSettingsExportData(date); - if (_settingsExportToken !== token) { - // A new export has been started - return; + a.dispatchEvent(new MouseEvent('click')); + setTimeout(revoke, 60000); } - _settingsExportToken = null; - const fileName = `yomichan-settings-${_getSettingsExportDateString(date, '-', '-', '-', 6)}.json`; - const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'}); - _saveBlob(blob, fileName); -} - - -// Importing + async _onSettingsExportClick() { + if (this._settingsExportRevoke !== null) { + this._settingsExportRevoke(); + this._settingsExportRevoke = null; + } -async function _settingsImportSetOptionsFull(optionsFull) { - return utilIsolate(utilBackend().setFullOptions( - utilBackgroundIsolate(optionsFull) - )); -} + const date = new Date(Date.now()); -function _showSettingsImportError(error) { - yomichan.logError(error); - document.querySelector('#settings-import-error-modal-message').textContent = `${error}`; - $('#settings-import-error-modal').modal('show'); -} + const token = {}; + this._settingsExportToken = token; + const data = await this._getSettingsExportData(date); + if (this._settingsExportToken !== token) { + // A new export has been started + return; + } + this._settingsExportToken = null; -async function _showSettingsImportWarnings(warnings) { - const modalNode = $('#settings-import-warning-modal'); - const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button'); - const messageContainer = document.querySelector('#settings-import-warning-modal-message'); - if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) { - return {result: false}; + const fileName = `yomichan-settings-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`; + const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'}); + this._saveBlob(blob, fileName); } - // Set message - const fragment = document.createDocumentFragment(); - for (const warning of warnings) { - const node = document.createElement('li'); - node.textContent = `${warning}`; - fragment.appendChild(node); + _readFileArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); } - messageContainer.textContent = ''; - messageContainer.appendChild(fragment); - - // Show modal - modalNode.modal('show'); - - // Wait for modal to close - return new Promise((resolve) => { - const onButtonClick = (e) => { - e.preventDefault(); - complete({ - result: true, - sanitize: e.currentTarget.dataset.importSanitize === 'true' - }); - modalNode.modal('hide'); - }; - const onModalHide = () => { - complete({result: false}); - }; - let completed = false; - const complete = (result) => { - if (completed) { return; } - completed = true; + // Importing - modalNode.off('hide.bs.modal', onModalHide); - for (const button of buttons) { - button.removeEventListener('click', onButtonClick, false); - } + async _settingsImportSetOptionsFull(optionsFull) { + await this._settingsController.setAllSettings(optionsFull); + } - resolve(result); - }; + _showSettingsImportError(error) { + yomichan.logError(error); + document.querySelector('#settings-import-error-modal-message').textContent = `${error}`; + $('#settings-import-error-modal').modal('show'); + } - // Hook events - modalNode.on('hide.bs.modal', onModalHide); - for (const button of buttons) { - button.addEventListener('click', onButtonClick, false); + async _showSettingsImportWarnings(warnings) { + const modalNode = $('#settings-import-warning-modal'); + const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button'); + const messageContainer = document.querySelector('#settings-import-warning-modal-message'); + if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) { + return {result: false}; } - }); -} -function _isLocalhostUrl(urlString) { - try { - const url = new URL(urlString); - switch (url.hostname.toLowerCase()) { - case 'localhost': - case '127.0.0.1': - case '[::1]': - switch (url.protocol.toLowerCase()) { - case 'http:': - case 'https:': - return true; - } - break; + // Set message + const fragment = document.createDocumentFragment(); + for (const warning of warnings) { + const node = document.createElement('li'); + node.textContent = `${warning}`; + fragment.appendChild(node); } - } catch (e) { - // NOP - } - return false; -} + messageContainer.textContent = ''; + messageContainer.appendChild(fragment); + + // Show modal + modalNode.modal('show'); + + // Wait for modal to close + return new Promise((resolve) => { + const onButtonClick = (e) => { + e.preventDefault(); + complete({ + result: true, + sanitize: e.currentTarget.dataset.importSanitize === 'true' + }); + modalNode.modal('hide'); + }; + const onModalHide = () => { + complete({result: false}); + }; + + let completed = false; + const complete = (result) => { + if (completed) { return; } + completed = true; + + modalNode.off('hide.bs.modal', onModalHide); + for (const button of buttons) { + button.removeEventListener('click', onButtonClick, false); + } -function _settingsImportSanitizeProfileOptions(options, dryRun) { - const warnings = []; + resolve(result); + }; - const anki = options.anki; - if (isObject(anki)) { - const fieldTemplates = anki.fieldTemplates; - if (typeof fieldTemplates === 'string') { - warnings.push('anki.fieldTemplates contains a non-default value'); - if (!dryRun) { - delete anki.fieldTemplates; - } - } - const server = anki.server; - if (typeof server === 'string' && server.length > 0 && !_isLocalhostUrl(server)) { - warnings.push('anki.server uses a non-localhost URL'); - if (!dryRun) { - delete anki.server; + // Hook events + modalNode.on('hide.bs.modal', onModalHide); + for (const button of buttons) { + button.addEventListener('click', onButtonClick, false); } - } + }); } - const audio = options.audio; - if (isObject(audio)) { - const customSourceUrl = audio.customSourceUrl; - if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !_isLocalhostUrl(customSourceUrl)) { - warnings.push('audio.customSourceUrl uses a non-localhost URL'); - if (!dryRun) { - delete audio.customSourceUrl; + _isLocalhostUrl(urlString) { + try { + const url = new URL(urlString); + switch (url.hostname.toLowerCase()) { + case 'localhost': + case '127.0.0.1': + case '[::1]': + switch (url.protocol.toLowerCase()) { + case 'http:': + case 'https:': + return true; + } + break; } + } catch (e) { + // NOP } + return false; } - return warnings; -} - -function _settingsImportSanitizeOptions(optionsFull, dryRun) { - const warnings = new Set(); + _settingsImportSanitizeProfileOptions(options, dryRun) { + const warnings = []; - const profiles = optionsFull.profiles; - if (Array.isArray(profiles)) { - for (const profile of profiles) { - if (!isObject(profile)) { continue; } - const options = profile.options; - if (!isObject(options)) { continue; } - - const warnings2 = _settingsImportSanitizeProfileOptions(options, dryRun); - for (const warning of warnings2) { - warnings.add(warning); + const anki = options.anki; + if (isObject(anki)) { + const fieldTemplates = anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + warnings.push('anki.fieldTemplates contains a non-default value'); + if (!dryRun) { + delete anki.fieldTemplates; + } + } + const server = anki.server; + if (typeof server === 'string' && server.length > 0 && !this._isLocalhostUrl(server)) { + warnings.push('anki.server uses a non-localhost URL'); + if (!dryRun) { + delete anki.server; + } } } - } - return warnings; -} + const audio = options.audio; + if (isObject(audio)) { + const customSourceUrl = audio.customSourceUrl; + if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !this._isLocalhostUrl(customSourceUrl)) { + warnings.push('audio.customSourceUrl uses a non-localhost URL'); + if (!dryRun) { + delete audio.customSourceUrl; + } + } + } -function _utf8Decode(arrayBuffer) { - try { - return new TextDecoder('utf-8').decode(arrayBuffer); - } catch (e) { - const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); - return decodeURIComponent(escape(binaryString)); + return warnings; } -} -async function _importSettingsFile(file) { - const dataString = _utf8Decode(await utilReadFileArrayBuffer(file)); - const data = JSON.parse(dataString); + _settingsImportSanitizeOptions(optionsFull, dryRun) { + const warnings = new Set(); - // Type check - if (!isObject(data)) { - throw new Error(`Invalid data type: ${typeof data}`); - } + const profiles = optionsFull.profiles; + if (Array.isArray(profiles)) { + for (const profile of profiles) { + if (!isObject(profile)) { continue; } + const options = profile.options; + if (!isObject(options)) { continue; } - // Version check - const version = data.version; - if (!( - typeof version === 'number' && - Number.isFinite(version) && - version === Math.floor(version) - )) { - throw new Error(`Invalid version: ${version}`); - } + const warnings2 = this._settingsImportSanitizeProfileOptions(options, dryRun); + for (const warning of warnings2) { + warnings.add(warning); + } + } + } - if (!( - version >= 0 && - version <= SETTINGS_EXPORT_CURRENT_VERSION - )) { - throw new Error(`Unsupported version: ${version}`); + return warnings; } - // Verify options exists - let optionsFull = data.options; - if (!isObject(optionsFull)) { - throw new Error(`Invalid options type: ${typeof optionsFull}`); + _utf8Decode(arrayBuffer) { + try { + return new TextDecoder('utf-8').decode(arrayBuffer); + } catch (e) { + const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); + return decodeURIComponent(escape(binaryString)); + } } - // Upgrade options - optionsFull = optionsUpdateVersion(optionsFull, {}); + async _importSettingsFile(file) { + const dataString = this._utf8Decode(await this._readFileArrayBuffer(file)); + const data = JSON.parse(dataString); - // Check for warnings - const sanitizationWarnings = _settingsImportSanitizeOptions(optionsFull, true); - - // Show sanitization warnings - if (sanitizationWarnings.size > 0) { - const {result, sanitize} = await _showSettingsImportWarnings(sanitizationWarnings); - if (!result) { return; } + // Type check + if (!isObject(data)) { + throw new Error(`Invalid data type: ${typeof data}`); + } - if (sanitize !== false) { - _settingsImportSanitizeOptions(optionsFull, false); + // Version check + const version = data.version; + if (!( + typeof version === 'number' && + Number.isFinite(version) && + version === Math.floor(version) + )) { + throw new Error(`Invalid version: ${version}`); } - } - // Assign options - await _settingsImportSetOptionsFull(optionsFull); + if (!( + version >= 0 && + version <= this._currentVersion + )) { + throw new Error(`Unsupported version: ${version}`); + } - // Reload settings page - window.location.reload(); -} + // Verify options exists + let optionsFull = data.options; + if (!isObject(optionsFull)) { + throw new Error(`Invalid options type: ${typeof optionsFull}`); + } -function _onSettingsImportClick() { - document.querySelector('#settings-import-file').click(); -} + // Upgrade options + optionsFull = optionsUpdateVersion(optionsFull, {}); -function _onSettingsImportFileChange(e) { - const files = e.target.files; - if (files.length === 0) { return; } + // Check for warnings + const sanitizationWarnings = this._settingsImportSanitizeOptions(optionsFull, true); - const file = files[0]; - e.target.value = null; - _importSettingsFile(file).catch(_showSettingsImportError); -} + // Show sanitization warnings + if (sanitizationWarnings.size > 0) { + const {result, sanitize} = await this._showSettingsImportWarnings(sanitizationWarnings); + if (!result) { return; } + if (sanitize !== false) { + this._settingsImportSanitizeOptions(optionsFull, false); + } + } -// Resetting + // Assign options + await this._settingsImportSetOptionsFull(optionsFull); + } -function _onSettingsResetClick() { - $('#settings-reset-modal').modal('show'); -} + _onSettingsImportClick() { + document.querySelector('#settings-import-file').click(); + } -async function _onSettingsResetConfirmClick() { - $('#settings-reset-modal').modal('hide'); + async _onSettingsImportFileChange(e) { + const files = e.target.files; + if (files.length === 0) { return; } - // Get default options - const optionsFull = optionsGetDefault(); + const file = files[0]; + e.target.value = null; + try { + await this._importSettingsFile(file); + } catch (error) { + this._showSettingsImportError(error); + } + } - // Assign options - await _settingsImportSetOptionsFull(optionsFull); + // Resetting - // Reload settings page - window.location.reload(); -} + _onSettingsResetClick() { + $('#settings-reset-modal').modal('show'); + } + async _onSettingsResetConfirmClick() { + $('#settings-reset-modal').modal('hide'); -// Setup + // Get default options + const optionsFull = optionsGetDefault(); -function backupInitialize() { - document.querySelector('#settings-export').addEventListener('click', _onSettingsExportClick, false); - document.querySelector('#settings-import').addEventListener('click', _onSettingsImportClick, false); - document.querySelector('#settings-import-file').addEventListener('change', _onSettingsImportFileChange, false); - document.querySelector('#settings-reset').addEventListener('click', _onSettingsResetClick, false); - document.querySelector('#settings-reset-modal-confirm').addEventListener('click', _onSettingsResetConfirmClick, false); + // Assign options + await this._settingsImportSetOptionsFull(optionsFull); + } } diff --git a/ext/bg/js/settings/clipboard-popups-controller.js b/ext/bg/js/settings/clipboard-popups-controller.js new file mode 100644 index 00000000..294663f9 --- /dev/null +++ b/ext/bg/js/settings/clipboard-popups-controller.js @@ -0,0 +1,51 @@ +/* + * 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 ClipboardPopupsController { + constructor(settingsController) { + this._settingsController = settingsController; + this._checkbox = document.querySelector('#enable-clipboard-popups'); + } + + async prepare() { + this._checkbox.addEventListener('change', this._onEnableClipboardPopupsChanged.bind(this), false); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private + + _onOptionsChanged({options}) { + this._checkbox.checked = options.general.enableClipboardPopups; + } + + async _onEnableClipboardPopupsChanged(e) { + const checkbox = e.currentTarget; + let value = checkbox.checked; + + if (value) { + value = await new Promise((resolve) => { + chrome.permissions.request({permissions: ['clipboardRead']}, resolve); + }); + checkbox.checked = value; + } + + await this._settingsController.setProfileSetting('general.enableClipboardPopups', value); + } +} diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index 632c01ea..94a71233 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -17,27 +17,13 @@ /* global * PageExitPrevention - * apiDeleteDictionary - * apiGetDictionaryCounts - * apiGetDictionaryInfo - * apiImportDictionaryArchive - * apiOptionsGet - * apiOptionsGetFull - * apiPurgeDatabase - * getOptionsContext - * getOptionsFullMutable - * getOptionsMutable - * settingsSaveOptions - * storageEstimate - * storageUpdateStats + * api * utilBackgroundIsolate */ -let dictionaryUI = null; - - -class SettingsDictionaryListUI { +class SettingsDictionaryListUI extends EventDispatcher { constructor(container, template, extraContainer, extraTemplate) { + super(); this.container = container; this.template = template; this.extraContainer = extraContainer; @@ -312,15 +298,15 @@ class SettingsDictionaryEntryUI { progressBar.style.width = `${percent}%`; }; - await apiDeleteDictionary(this.dictionaryInfo.title, onProgress); + await api.deleteDictionary(this.dictionaryInfo.title, onProgress); } catch (e) { - dictionaryErrorsShow([e]); + this.dictionaryErrorsShow([e]); } finally { prevention.end(); this.isDeleting = false; progress.hidden = true; - onDatabaseUpdated(); + this.parent.trigger('databaseUpdated'); } } @@ -394,340 +380,342 @@ class SettingsDictionaryExtraUI { } } +class DictionaryController { + constructor(settingsController, storageController) { + this._settingsController = settingsController; + this._storageController = storageController; + this._dictionaryUI = null; + this._dictionaryErrorToStringOverrides = [ + [ + 'A mutation operation was attempted on a database that did not allow mutations.', + 'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.' + ], + [ + 'The operation failed for reasons unrelated to the database itself and not covered by any other error code.', + 'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.' + ], + [ + 'BulkError', + 'Unable to finish importing dictionary data into IndexedDB. This may indicate that you do not have sufficient disk space available to complete this operation.' + ] + ]; + } -async function dictSettingsInitialize() { - dictionaryUI = new SettingsDictionaryListUI( - document.querySelector('#dict-groups'), - document.querySelector('#dict-template'), - document.querySelector('#dict-groups-extra'), - document.querySelector('#dict-extra-template') - ); - dictionaryUI.save = settingsSaveOptions; - - document.querySelector('#dict-purge-button').addEventListener('click', onDictionaryPurgeButtonClick, false); - document.querySelector('#dict-purge-confirm').addEventListener('click', onDictionaryPurge, false); - document.querySelector('#dict-file-button').addEventListener('click', onDictionaryImportButtonClick, false); - document.querySelector('#dict-file').addEventListener('change', onDictionaryImport, false); - document.querySelector('#dict-main').addEventListener('change', onDictionaryMainChanged, false); - document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', onDatabaseEnablePrefixWildcardSearchesChanged, false); - - await onDictionaryOptionsChanged(); - await onDatabaseUpdated(); -} - -async function onDictionaryOptionsChanged() { - if (dictionaryUI === null) { return; } + async prepare() { + this._dictionaryUI = new SettingsDictionaryListUI( + document.querySelector('#dict-groups'), + document.querySelector('#dict-template'), + document.querySelector('#dict-groups-extra'), + document.querySelector('#dict-extra-template') + ); + this._dictionaryUI.save = () => this._settingsController.save(); + this._dictionaryUI.on('databaseUpdated', this._onDatabaseUpdated.bind(this)); - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); + document.querySelector('#dict-purge-button').addEventListener('click', this._onPurgeButtonClick.bind(this), false); + document.querySelector('#dict-purge-confirm').addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false); + document.querySelector('#dict-file-button').addEventListener('click', this._onImportButtonClick.bind(this), false); + document.querySelector('#dict-file').addEventListener('change', this._onImportFileChange.bind(this), false); + document.querySelector('#dict-main').addEventListener('change', this._onDictionaryMainChanged.bind(this), false); + document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', this._onDatabaseEnablePrefixWildcardSearchesChanged.bind(this), false); - dictionaryUI.setOptionsDictionaries(options.dictionaries); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); - const optionsFull = await apiOptionsGetFull(); - document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported; + await this._onOptionsChanged(); + await this._onDatabaseUpdated(); + } - await updateMainDictionarySelectValue(); -} + // Private -async function onDatabaseUpdated() { - try { - const dictionaries = await apiGetDictionaryInfo(); - dictionaryUI.setDictionaries(dictionaries); + async _onOptionsChanged() { + const options = await this._settingsController.getOptionsMutable(); - document.querySelector('#dict-warning').hidden = (dictionaries.length > 0); + this._dictionaryUI.setOptionsDictionaries(options.dictionaries); - updateMainDictionarySelectOptions(dictionaries); - await updateMainDictionarySelectValue(); + const optionsFull = await this._settingsController.getOptionsFull(); + document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported; - const {counts, total} = await apiGetDictionaryCounts(dictionaries.map((v) => v.title), true); - dictionaryUI.setCounts(counts, total); - } catch (e) { - dictionaryErrorsShow([e]); + await this._updateMainDictionarySelectValue(); } -} -function updateMainDictionarySelectOptions(dictionaries) { - const select = document.querySelector('#dict-main'); - select.textContent = ''; // Empty + _updateMainDictionarySelectOptions(dictionaries) { + const select = document.querySelector('#dict-main'); + select.textContent = ''; // Empty - let option = document.createElement('option'); - option.className = 'text-muted'; - option.value = ''; - option.textContent = 'Not selected'; - select.appendChild(option); + let option = document.createElement('option'); + option.className = 'text-muted'; + option.value = ''; + option.textContent = 'Not selected'; + select.appendChild(option); - for (const {title, sequenced} of toIterable(dictionaries)) { - if (!sequenced) { continue; } + for (const {title, sequenced} of toIterable(dictionaries)) { + if (!sequenced) { continue; } - option = document.createElement('option'); - option.value = title; - option.textContent = title; - select.appendChild(option); + option = document.createElement('option'); + option.value = title; + option.textContent = title; + select.appendChild(option); + } } -} -async function updateMainDictionarySelectValue() { - const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + async _updateMainDictionarySelectValue() { + const options = await this._settingsController.getOptions(); - const value = options.general.mainDictionary; + const value = options.general.mainDictionary; - const select = document.querySelector('#dict-main'); - let selectValue = null; - for (const child of select.children) { - if (child.nodeName.toUpperCase() === 'OPTION' && child.value === value) { - selectValue = value; - break; + const select = document.querySelector('#dict-main'); + let selectValue = null; + for (const child of select.children) { + if (child.nodeName.toUpperCase() === 'OPTION' && child.value === value) { + selectValue = value; + break; + } } - } - let missingNodeOption = select.querySelector('option[data-not-installed=true]'); - if (selectValue === null) { - if (missingNodeOption === null) { - missingNodeOption = document.createElement('option'); - missingNodeOption.className = 'text-muted'; - missingNodeOption.value = value; - missingNodeOption.textContent = `${value} (Not installed)`; - missingNodeOption.dataset.notInstalled = 'true'; - select.appendChild(missingNodeOption); - } - } else { - if (missingNodeOption !== null) { - missingNodeOption.parentNode.removeChild(missingNodeOption); + let missingNodeOption = select.querySelector('option[data-not-installed=true]'); + if (selectValue === null) { + if (missingNodeOption === null) { + missingNodeOption = document.createElement('option'); + missingNodeOption.className = 'text-muted'; + missingNodeOption.value = value; + missingNodeOption.textContent = `${value} (Not installed)`; + missingNodeOption.dataset.notInstalled = 'true'; + select.appendChild(missingNodeOption); + } + } else { + if (missingNodeOption !== null) { + missingNodeOption.parentNode.removeChild(missingNodeOption); + } } + + select.value = value; } - select.value = value; -} + _dictionaryErrorToString(error) { + if (error.toString) { + error = error.toString(); + } else { + error = `${error}`; + } -async function onDictionaryMainChanged(e) { - const select = e.target; - const value = select.value; + for (const [match, subst] of this._dictionaryErrorToStringOverrides) { + if (error.includes(match)) { + error = subst; + break; + } + } - const missingNodeOption = select.querySelector('option[data-not-installed=true]'); - if (missingNodeOption !== null && missingNodeOption.value !== value) { - missingNodeOption.parentNode.removeChild(missingNodeOption); + return error; } - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - options.general.mainDictionary = value; - await settingsSaveOptions(); -} + _dictionaryErrorsShow(errors) { + const dialog = document.querySelector('#dict-error'); + dialog.textContent = ''; + if (errors !== null && errors.length > 0) { + const uniqueErrors = new Map(); + for (let e of errors) { + yomichan.logError(e); + e = this._dictionaryErrorToString(e); + let count = uniqueErrors.get(e); + if (typeof count === 'undefined') { + count = 0; + } + uniqueErrors.set(e, count + 1); + } -function dictionaryErrorToString(error) { - if (error.toString) { - error = error.toString(); - } else { - error = `${error}`; - } + for (const [e, count] of uniqueErrors.entries()) { + const div = document.createElement('p'); + if (count > 1) { + div.textContent = `${e} `; + const em = document.createElement('em'); + em.textContent = `(${count})`; + div.appendChild(em); + } else { + div.textContent = `${e}`; + } + dialog.appendChild(div); + } - for (const [match, subst] of dictionaryErrorToString.overrides) { - if (error.includes(match)) { - error = subst; - break; + dialog.hidden = false; + } else { + dialog.hidden = true; } } - return error; -} -dictionaryErrorToString.overrides = [ - [ - 'A mutation operation was attempted on a database that did not allow mutations.', - 'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.' - ], - [ - 'The operation failed for reasons unrelated to the database itself and not covered by any other error code.', - 'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.' - ], - [ - 'BulkError', - 'Unable to finish importing dictionary data into IndexedDB. This may indicate that you do not have sufficient disk space available to complete this operation.' - ] -]; - -function dictionaryErrorsShow(errors) { - const dialog = document.querySelector('#dict-error'); - dialog.textContent = ''; - - if (errors !== null && errors.length > 0) { - const uniqueErrors = new Map(); - for (let e of errors) { - yomichan.logError(e); - e = dictionaryErrorToString(e); - let count = uniqueErrors.get(e); - if (typeof count === 'undefined') { - count = 0; - } - uniqueErrors.set(e, count + 1); - } - - for (const [e, count] of uniqueErrors.entries()) { - const div = document.createElement('p'); - if (count > 1) { - div.textContent = `${e} `; - const em = document.createElement('em'); - em.textContent = `(${count})`; - div.appendChild(em); - } else { - div.textContent = `${e}`; - } - dialog.appendChild(div); + _dictionarySpinnerShow(show) { + const spinner = $('#dict-spinner'); + if (show) { + spinner.show(); + } else { + spinner.hide(); } + } - dialog.hidden = false; - } else { - dialog.hidden = true; + _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 _onDatabaseUpdated() { + try { + const dictionaries = await api.getDictionaryInfo(); + this._dictionaryUI.setDictionaries(dictionaries); + + document.querySelector('#dict-warning').hidden = (dictionaries.length > 0); -function dictionarySpinnerShow(show) { - const spinner = $('#dict-spinner'); - if (show) { - spinner.show(); - } else { - spinner.hide(); + this._updateMainDictionarySelectOptions(dictionaries); + await this._updateMainDictionarySelectValue(); + + const {counts, total} = await api.getDictionaryCounts(dictionaries.map((v) => v.title), true); + this._dictionaryUI.setCounts(counts, total); + } catch (e) { + this._dictionaryErrorsShow([e]); + } } -} -function onDictionaryImportButtonClick() { - const dictFile = document.querySelector('#dict-file'); - dictFile.click(); -} + async _onDictionaryMainChanged(e) { + const select = e.target; + const value = select.value; -function onDictionaryPurgeButtonClick(e) { - e.preventDefault(); - $('#dict-purge-modal').modal('show'); -} + const missingNodeOption = select.querySelector('option[data-not-installed=true]'); + if (missingNodeOption !== null && missingNodeOption.value !== value) { + missingNodeOption.parentNode.removeChild(missingNodeOption); + } -async function onDictionaryPurge(e) { - e.preventDefault(); + const options = await this._settingsController.getOptionsMutable(); + options.general.mainDictionary = value; + await this._settingsController.save(); + } - $('#dict-purge-modal').modal('hide'); + _onImportButtonClick() { + const dictFile = document.querySelector('#dict-file'); + dictFile.click(); + } - const dictControls = $('#dict-importer, #dict-groups, #dict-groups-extra, #dict-main-group').hide(); - const dictProgress = document.querySelector('#dict-purge'); - dictProgress.hidden = false; + _onPurgeButtonClick(e) { + e.preventDefault(); + $('#dict-purge-modal').modal('show'); + } - const prevention = new PageExitPrevention(); + async _onPurgeConfirmButtonClick(e) { + e.preventDefault(); - try { - prevention.start(); - dictionaryErrorsShow(null); - dictionarySpinnerShow(true); + $('#dict-purge-modal').modal('hide'); - await apiPurgeDatabase(); - for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) { - options.dictionaries = utilBackgroundIsolate({}); - options.general.mainDictionary = ''; - } - await settingsSaveOptions(); + const dictControls = $('#dict-importer, #dict-groups, #dict-groups-extra, #dict-main-group').hide(); + const dictProgress = document.querySelector('#dict-purge'); + dictProgress.hidden = false; + + const prevention = new PageExitPrevention(); - onDatabaseUpdated(); - } catch (err) { - dictionaryErrorsShow([err]); - } finally { - prevention.end(); + try { + prevention.start(); + this._dictionaryErrorsShow(null); + this._dictionarySpinnerShow(true); + + await api.purgeDatabase(); + const optionsFull = await this._settingsController.getOptionsFullMutable(); + for (const {options} of toIterable(optionsFull.profiles)) { + options.dictionaries = utilBackgroundIsolate({}); + options.general.mainDictionary = ''; + } + await this._settingsController.save(); + + this._onDatabaseUpdated(); + } catch (err) { + this._dictionaryErrorsShow([err]); + } finally { + prevention.end(); - dictionarySpinnerShow(false); + this._dictionarySpinnerShow(false); - dictControls.show(); - dictProgress.hidden = true; + dictControls.show(); + dictProgress.hidden = true; - if (storageEstimate.mostRecent !== null) { - storageUpdateStats(); + this._storageController.updateStats(); } } -} -async function onDictionaryImport(e) { - const files = [...e.target.files]; - e.target.value = null; + async _onImportFileChange(e) { + const files = [...e.target.files]; + e.target.value = null; - const dictFile = $('#dict-file'); - const dictControls = $('#dict-importer').hide(); - const dictProgress = $('#dict-import-progress').show(); - const dictImportInfo = document.querySelector('#dict-import-info'); + const dictFile = $('#dict-file'); + const dictControls = $('#dict-importer').hide(); + const dictProgress = $('#dict-import-progress').show(); + const dictImportInfo = document.querySelector('#dict-import-info'); - const prevention = new PageExitPrevention(); + const prevention = new PageExitPrevention(); - try { - prevention.start(); - dictionaryErrorsShow(null); - dictionarySpinnerShow(true); + try { + prevention.start(); + this._dictionaryErrorsShow(null); + this._dictionarySpinnerShow(true); - const setProgress = (percent) => dictProgress.find('.progress-bar').css('width', `${percent}%`); - const updateProgress = (total, current) => { - setProgress(current / total * 100.0); - if (storageEstimate.mostRecent !== null && !storageUpdateStats.isUpdating) { - storageUpdateStats(); - } - }; + const setProgress = (percent) => dictProgress.find('.progress-bar').css('width', `${percent}%`); + const updateProgress = (total, current) => { + setProgress(current / total * 100.0); + this._storageController.updateStats(); + }; - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await this._settingsController.getOptionsFull(); - const importDetails = { - prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported - }; + const importDetails = { + prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported + }; - for (let i = 0, ii = files.length; i < ii; ++i) { - setProgress(0.0); - if (ii > 1) { - dictImportInfo.hidden = false; - dictImportInfo.textContent = `(${i + 1} of ${ii})`; - } + for (let i = 0, ii = files.length; i < ii; ++i) { + setProgress(0.0); + if (ii > 1) { + dictImportInfo.hidden = false; + dictImportInfo.textContent = `(${i + 1} of ${ii})`; + } - 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; - options.dictionaries[result.title] = dictionaryOptions; - if (result.sequenced && options.general.mainDictionary === '') { - options.general.mainDictionary = result.title; + const archiveContent = await this._dictReadFile(files[i]); + const {result, errors} = await api.importDictionaryArchive(archiveContent, importDetails, updateProgress); + const optionsFull2 = await this._settingsController.getOptionsFullMutable(); + for (const {options} of toIterable(optionsFull2.profiles)) { + const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions(); + dictionaryOptions.enabled = true; + options.dictionaries[result.title] = dictionaryOptions; + if (result.sequenced && options.general.mainDictionary === '') { + options.general.mainDictionary = result.title; + } } - } - await settingsSaveOptions(); + await this._settingsController.save(); - if (errors.length > 0) { - const errors2 = errors.map((error) => jsonToError(error)); - errors2.push(`Dictionary may not have been imported properly: ${errors2.length} error${errors2.length === 1 ? '' : 's'} reported.`); - dictionaryErrorsShow(errors2); + if (errors.length > 0) { + const errors2 = errors.map((error) => jsonToError(error)); + errors2.push(`Dictionary may not have been imported properly: ${errors2.length} error${errors2.length === 1 ? '' : 's'} reported.`); + this._dictionaryErrorsShow(errors2); + } + + this._onDatabaseUpdated(); } + } catch (err) { + this._dictionaryErrorsShow([err]); + } finally { + prevention.end(); + this._dictionarySpinnerShow(false); - onDatabaseUpdated(); + dictImportInfo.hidden = false; + dictImportInfo.textContent = ''; + dictFile.val(''); + dictControls.show(); + dictProgress.hide(); } - } catch (err) { - dictionaryErrorsShow([err]); - } finally { - prevention.end(); - dictionarySpinnerShow(false); - - dictImportInfo.hidden = false; - dictImportInfo.textContent = ''; - dictFile.val(''); - dictControls.show(); - dictProgress.hide(); } -} - -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(); - const v = !!e.target.checked; - if (optionsFull.global.database.prefixWildcardsSupported === v) { return; } - optionsFull.global.database.prefixWildcardsSupported = !!e.target.checked; - await settingsSaveOptions(); + async _onDatabaseEnablePrefixWildcardSearchesChanged(e) { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const v = !!e.target.checked; + if (optionsFull.global.database.prefixWildcardsSupported === v) { return; } + optionsFull.global.database.prefixWildcardsSupported = !!e.target.checked; + await this._settingsController.save(); + } } diff --git a/ext/bg/js/settings/generic-setting-controller.js b/ext/bg/js/settings/generic-setting-controller.js new file mode 100644 index 00000000..bdea8e3d --- /dev/null +++ b/ext/bg/js/settings/generic-setting-controller.js @@ -0,0 +1,132 @@ +/* + * 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/>. + */ + +/* globals + * DOMDataBinder + */ + +class GenericSettingController { + constructor(settingsController) { + this._settingsController = settingsController; + this._defaultScope = 'profile'; + this._dataBinder = new DOMDataBinder({ + selector: '[data-setting]', + createElementMetadata: this._createElementMetadata.bind(this), + compareElementMetadata: this._compareElementMetadata.bind(this), + getValues: this._getValues.bind(this), + setValues: this._setValues.bind(this) + }); + this._transforms = new Map([ + ['setDocumentAttribute', this._setDocumentAttribute.bind(this)], + ['splitTags', this._splitTags.bind(this)], + ['joinTags', this._joinTags.bind(this)] + ]); + } + + async prepare() { + this._dataBinder.observe(document.body); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + } + + // Private + + _onOptionsChanged() { + this._dataBinder.refresh(); + } + + _createElementMetadata(element) { + return { + path: element.dataset.setting, + scope: element.dataset.scope, + transformPre: element.dataset.transformPre, + transformPost: element.dataset.transformPost + }; + } + + _compareElementMetadata(metadata1, metadata2) { + return ( + metadata1.path === metadata2.path && + metadata1.scope === metadata2.scope && + metadata1.transformPre === metadata2.transformPre && + metadata1.transformPost === metadata2.transformPost + ); + } + + async _getValues(targets) { + const defaultScope = this._defaultScope; + const settingsTargets = []; + for (const {metadata: {path, scope}} of targets) { + const target = { + path, + scope: scope || defaultScope + }; + settingsTargets.push(target); + } + return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets); + } + + async _setValues(targets) { + const defaultScope = this._defaultScope; + const settingsTargets = []; + for (const {metadata, value, element} of targets) { + const {path, scope, transformPre} = metadata; + const target = { + path, + scope: scope || defaultScope, + action: 'set', + value: this._transform(value, transformPre, metadata, element) + }; + settingsTargets.push(target); + } + return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets); + } + + _transformResults(values, targets) { + return values.map((value, i) => { + const error = value.error; + if (error) { return jsonToError(error); } + const {metadata, element} = targets[i]; + const result = this._transform(value.result, metadata.transformPost, metadata, element); + return {result}; + }); + } + + _transform(value, transform, metadata, element) { + if (typeof transform === 'string') { + const transformFunction = this._transforms.get(transform); + if (typeof transformFunction !== 'undefined') { + value = transformFunction(value, metadata, element); + } + } + return value; + } + + // Transforms + + _setDocumentAttribute(value, metadata, element) { + document.documentElement.setAttribute(element.dataset.documentAttribute, `${value}`); + return value; + } + + _splitTags(value) { + return `${value}`.split(/[,; ]+/).filter((v) => !!v); + } + + _joinTags(value) { + return value.join(' '); + } +} diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index 61395b1c..e22c5e53 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -16,268 +16,20 @@ */ /* global - * ankiErrorShown - * ankiFieldsToDict - * ankiInitialize - * ankiTemplatesInitialize - * ankiTemplatesUpdateValue - * apiForwardLogsToBackend - * apiGetEnvironmentInfo - * apiOptionsSave - * appearanceInitialize - * audioSettingsInitialize - * backupInitialize - * dictSettingsInitialize - * getOptionsContext - * onAnkiOptionsChanged - * onDictionaryOptionsChanged - * profileOptionsSetup - * storageInfoInitialize - * utilBackend - * utilBackgroundIsolate - * utilIsolate + * AnkiController + * AnkiTemplatesController + * AudioController + * ClipboardPopupsController + * DictionaryController + * GenericSettingController + * PopupPreviewController + * ProfileController + * SettingsBackup + * SettingsController + * StorageController + * api */ -function getOptionsMutable(optionsContext) { - return utilBackend().getOptions( - utilBackgroundIsolate(optionsContext) - ); -} - -function getOptionsFullMutable() { - return utilBackend().getFullOptions(); -} - -async function formRead(options) { - options.general.enable = $('#enable').prop('checked'); - const enableClipboardPopups = $('#enable-clipboard-popups').prop('checked'); - if (enableClipboardPopups) { - options.general.enableClipboardPopups = await new Promise((resolve, _reject) => { - chrome.permissions.request( - {permissions: ['clipboardRead']}, - (granted) => { - if (!granted) { - $('#enable-clipboard-popups').prop('checked', false); - } - resolve(granted); - } - ); - }); - } else { - options.general.enableClipboardPopups = false; - } - options.general.showGuide = $('#show-usage-guide').prop('checked'); - options.general.compactTags = $('#compact-tags').prop('checked'); - options.general.compactGlossaries = $('#compact-glossaries').prop('checked'); - options.general.resultOutputMode = $('#result-output-mode').val(); - options.general.debugInfo = $('#show-debug-info').prop('checked'); - options.general.showAdvanced = $('#show-advanced-options').prop('checked'); - options.general.maxResults = parseInt($('#max-displayed-results').val(), 10); - options.general.popupDisplayMode = $('#popup-display-mode').val(); - options.general.popupHorizontalTextPosition = $('#popup-horizontal-text-position').val(); - options.general.popupVerticalTextPosition = $('#popup-vertical-text-position').val(); - options.general.popupWidth = parseInt($('#popup-width').val(), 10); - options.general.popupHeight = parseInt($('#popup-height').val(), 10); - options.general.popupHorizontalOffset = parseInt($('#popup-horizontal-offset').val(), 0); - options.general.popupVerticalOffset = parseInt($('#popup-vertical-offset').val(), 10); - options.general.popupHorizontalOffset2 = parseInt($('#popup-horizontal-offset2').val(), 0); - options.general.popupVerticalOffset2 = parseInt($('#popup-vertical-offset2').val(), 10); - options.general.popupScalingFactor = parseFloat($('#popup-scaling-factor').val()); - options.general.popupScaleRelativeToPageZoom = $('#popup-scale-relative-to-page-zoom').prop('checked'); - options.general.popupScaleRelativeToVisualViewport = $('#popup-scale-relative-to-visual-viewport').prop('checked'); - options.general.showPitchAccentDownstepNotation = $('#show-pitch-accent-downstep-notation').prop('checked'); - options.general.showPitchAccentPositionNotation = $('#show-pitch-accent-position-notation').prop('checked'); - options.general.showPitchAccentGraph = $('#show-pitch-accent-graph').prop('checked'); - options.general.showIframePopupsInRootFrame = $('#show-iframe-popups-in-root-frame').prop('checked'); - options.general.popupTheme = $('#popup-theme').val(); - options.general.popupOuterTheme = $('#popup-outer-theme').val(); - options.general.customPopupCss = $('#custom-popup-css').val(); - options.general.customPopupOuterCss = $('#custom-popup-outer-css').val(); - - options.audio.enabled = $('#audio-playback-enabled').prop('checked'); - options.audio.autoPlay = $('#auto-play-audio').prop('checked'); - options.audio.volume = parseFloat($('#audio-playback-volume').val()); - options.audio.customSourceUrl = $('#audio-custom-source').val(); - options.audio.textToSpeechVoice = $('#text-to-speech-voice').val(); - - options.scanning.middleMouse = $('#middle-mouse-button-scan').prop('checked'); - options.scanning.touchInputEnabled = $('#touch-input-enabled').prop('checked'); - options.scanning.selectText = $('#select-matched-text').prop('checked'); - options.scanning.alphanumeric = $('#search-alphanumeric').prop('checked'); - options.scanning.autoHideResults = $('#auto-hide-results').prop('checked'); - options.scanning.deepDomScan = $('#deep-dom-scan').prop('checked'); - options.scanning.enablePopupSearch = $('#enable-search-within-first-popup').prop('checked'); - options.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked'); - options.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked'); - options.scanning.enableSearchTags = $('#enable-search-tags').prop('checked'); - options.scanning.delay = parseInt($('#scan-delay').val(), 10); - options.scanning.length = parseInt($('#scan-length').val(), 10); - options.scanning.modifier = $('#scan-modifier-key').val(); - options.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10); - - options.translation.convertHalfWidthCharacters = $('#translation-convert-half-width-characters').val(); - options.translation.convertNumericCharacters = $('#translation-convert-numeric-characters').val(); - options.translation.convertAlphabeticCharacters = $('#translation-convert-alphabetic-characters').val(); - options.translation.convertHiraganaToKatakana = $('#translation-convert-hiragana-to-katakana').val(); - options.translation.convertKatakanaToHiragana = $('#translation-convert-katakana-to-hiragana').val(); - options.translation.collapseEmphaticSequences = $('#translation-collapse-emphatic-sequences').val(); - - options.parsing.enableScanningParser = $('#parsing-scan-enable').prop('checked'); - options.parsing.enableMecabParser = $('#parsing-mecab-enable').prop('checked'); - options.parsing.termSpacing = $('#parsing-term-spacing').prop('checked'); - options.parsing.readingMode = $('#parsing-reading-mode').val(); - - const optionsAnkiEnableOld = options.anki.enable; - options.anki.enable = $('#anki-enable').prop('checked'); - 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); - - if (optionsAnkiEnableOld && !ankiErrorShown()) { - options.anki.terms.deck = $('#anki-terms-deck').val(); - options.anki.terms.model = $('#anki-terms-model').val(); - options.anki.terms.fields = utilBackgroundIsolate(ankiFieldsToDict(document.querySelectorAll('#terms .anki-field-value'))); - options.anki.kanji.deck = $('#anki-kanji-deck').val(); - options.anki.kanji.model = $('#anki-kanji-model').val(); - options.anki.kanji.fields = utilBackgroundIsolate(ankiFieldsToDict(document.querySelectorAll('#kanji .anki-field-value'))); - } -} - -async function formWrite(options) { - $('#enable').prop('checked', options.general.enable); - $('#enable-clipboard-popups').prop('checked', options.general.enableClipboardPopups); - $('#show-usage-guide').prop('checked', options.general.showGuide); - $('#compact-tags').prop('checked', options.general.compactTags); - $('#compact-glossaries').prop('checked', options.general.compactGlossaries); - $('#result-output-mode').val(options.general.resultOutputMode); - $('#show-debug-info').prop('checked', options.general.debugInfo); - $('#show-advanced-options').prop('checked', options.general.showAdvanced); - $('#max-displayed-results').val(options.general.maxResults); - $('#popup-display-mode').val(options.general.popupDisplayMode); - $('#popup-horizontal-text-position').val(options.general.popupHorizontalTextPosition); - $('#popup-vertical-text-position').val(options.general.popupVerticalTextPosition); - $('#popup-width').val(options.general.popupWidth); - $('#popup-height').val(options.general.popupHeight); - $('#popup-horizontal-offset').val(options.general.popupHorizontalOffset); - $('#popup-vertical-offset').val(options.general.popupVerticalOffset); - $('#popup-horizontal-offset2').val(options.general.popupHorizontalOffset2); - $('#popup-vertical-offset2').val(options.general.popupVerticalOffset2); - $('#popup-scaling-factor').val(options.general.popupScalingFactor); - $('#popup-scale-relative-to-page-zoom').prop('checked', options.general.popupScaleRelativeToPageZoom); - $('#popup-scale-relative-to-visual-viewport').prop('checked', options.general.popupScaleRelativeToVisualViewport); - $('#show-pitch-accent-downstep-notation').prop('checked', options.general.showPitchAccentDownstepNotation); - $('#show-pitch-accent-position-notation').prop('checked', options.general.showPitchAccentPositionNotation); - $('#show-pitch-accent-graph').prop('checked', options.general.showPitchAccentGraph); - $('#show-iframe-popups-in-root-frame').prop('checked', options.general.showIframePopupsInRootFrame); - $('#popup-theme').val(options.general.popupTheme); - $('#popup-outer-theme').val(options.general.popupOuterTheme); - $('#custom-popup-css').val(options.general.customPopupCss); - $('#custom-popup-outer-css').val(options.general.customPopupOuterCss); - - $('#audio-playback-enabled').prop('checked', options.audio.enabled); - $('#auto-play-audio').prop('checked', options.audio.autoPlay); - $('#audio-playback-volume').val(options.audio.volume); - $('#audio-custom-source').val(options.audio.customSourceUrl); - $('#text-to-speech-voice').val(options.audio.textToSpeechVoice).attr('data-value', options.audio.textToSpeechVoice); - - $('#middle-mouse-button-scan').prop('checked', options.scanning.middleMouse); - $('#touch-input-enabled').prop('checked', options.scanning.touchInputEnabled); - $('#select-matched-text').prop('checked', options.scanning.selectText); - $('#search-alphanumeric').prop('checked', options.scanning.alphanumeric); - $('#auto-hide-results').prop('checked', options.scanning.autoHideResults); - $('#deep-dom-scan').prop('checked', options.scanning.deepDomScan); - $('#enable-search-within-first-popup').prop('checked', options.scanning.enablePopupSearch); - $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions); - $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage); - $('#enable-search-tags').prop('checked', options.scanning.enableSearchTags); - $('#scan-delay').val(options.scanning.delay); - $('#scan-length').val(options.scanning.length); - $('#scan-modifier-key').val(options.scanning.modifier); - $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth); - - $('#translation-convert-half-width-characters').val(options.translation.convertHalfWidthCharacters); - $('#translation-convert-numeric-characters').val(options.translation.convertNumericCharacters); - $('#translation-convert-alphabetic-characters').val(options.translation.convertAlphabeticCharacters); - $('#translation-convert-hiragana-to-katakana').val(options.translation.convertHiraganaToKatakana); - $('#translation-convert-katakana-to-hiragana').val(options.translation.convertKatakanaToHiragana); - $('#translation-collapse-emphatic-sequences').val(options.translation.collapseEmphaticSequences); - - $('#parsing-scan-enable').prop('checked', options.parsing.enableScanningParser); - $('#parsing-mecab-enable').prop('checked', options.parsing.enableMecabParser); - $('#parsing-term-spacing').prop('checked', options.parsing.termSpacing); - $('#parsing-reading-mode').val(options.parsing.readingMode); - - $('#anki-enable').prop('checked', options.anki.enable); - $('#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); - - await ankiTemplatesUpdateValue(); - await onAnkiOptionsChanged(options); - await onDictionaryOptionsChanged(); - - formUpdateVisibility(options); -} - -function formSetupEventListeners() { - $('input, select, textarea').not('.anki-model').not('.ignore-form-changes *').change(onFormOptionsChanged); -} - -function formUpdateVisibility(options) { - document.documentElement.dataset.optionsAnkiEnable = `${!!options.anki.enable}`; - document.documentElement.dataset.optionsGeneralDebugInfo = `${!!options.general.debugInfo}`; - document.documentElement.dataset.optionsGeneralShowAdvanced = `${!!options.general.showAdvanced}`; - document.documentElement.dataset.optionsGeneralResultOutputMode = `${options.general.resultOutputMode}`; - - if (options.general.debugInfo) { - const temp = utilIsolate(options); - if (typeof temp.anki.fieldTemplates === 'string') { - temp.anki.fieldTemplates = '...'; - } - const text = JSON.stringify(temp, null, 4); - $('#debug').text(text); - } -} - -async function onFormOptionsChanged() { - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - - await formRead(options); - await settingsSaveOptions(); - formUpdateVisibility(options); - - await onAnkiOptionsChanged(options); -} - - -function settingsGetSource() { - return new Promise((resolve) => { - chrome.tabs.getCurrent((tab) => resolve(`settings${tab ? tab.id : ''}`)); - }); -} - -async function settingsSaveOptions() { - const source = await settingsGetSource(); - await apiOptionsSave(source); -} - -async function onOptionsUpdated({source}) { - const thisSource = await settingsGetSource(); - if (source === thisSource) { return; } - - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - await formWrite(options); -} - - function showExtensionInformation() { const node = document.getElementById('extension-info'); if (node === null) { return; } @@ -290,7 +42,7 @@ async function settingsPopulateModifierKeys() { const scanModifierKeySelect = document.querySelector('#scan-modifier-key'); scanModifierKeySelect.textContent = ''; - const environment = await apiGetEnvironmentInfo(); + const environment = await api.getEnvironmentInfo(); const modifierKeys = [ {value: 'none', name: 'None'}, ...environment.modifiers.keys @@ -303,26 +55,53 @@ async function settingsPopulateModifierKeys() { } } +async function setupEnvironmentInfo() { + const {browser, platform} = await api.getEnvironmentInfo(); + document.documentElement.dataset.browser = browser; + document.documentElement.dataset.operatingSystem = platform.os; +} + -async function onReady() { - apiForwardLogsToBackend(); +(async () => { + api.forwardLogsToBackend(); await yomichan.prepare(); + setupEnvironmentInfo(); showExtensionInformation(); + settingsPopulateModifierKeys(); - await settingsPopulateModifierKeys(); - formSetupEventListeners(); - appearanceInitialize(); - await audioSettingsInitialize(); - await profileOptionsSetup(); - await dictSettingsInitialize(); - ankiInitialize(); - ankiTemplatesInitialize(); - backupInitialize(); + const optionsFull = await api.optionsGetFull(); - storageInfoInitialize(); + const settingsController = new SettingsController(optionsFull.profileCurrent); + settingsController.prepare(); - yomichan.on('optionsUpdated', onOptionsUpdated); -} + const storageController = new StorageController(); + storageController.prepare(); + + const genericSettingController = new GenericSettingController(settingsController); + genericSettingController.prepare(); + + const clipboardPopupsController = new ClipboardPopupsController(settingsController); + clipboardPopupsController.prepare(); + + const popupPreviewController = new PopupPreviewController(settingsController); + popupPreviewController.prepare(); + + const audioController = new AudioController(settingsController); + audioController.prepare(); + + const profileController = new ProfileController(settingsController); + profileController.prepare(); + + const dictionaryController = new DictionaryController(settingsController, storageController); + dictionaryController.prepare(); + + const ankiController = new AnkiController(settingsController); + ankiController.prepare(); + + const ankiTemplatesController = new AnkiTemplatesController(settingsController, ankiController); + ankiTemplatesController.prepare(); -$(document).ready(() => onReady()); + const settingsBackup = new SettingsBackup(settingsController); + settingsBackup.prepare(); +})(); diff --git a/ext/bg/js/settings/popup-preview-frame-main.js b/ext/bg/js/settings/popup-preview-frame-main.js index 8228125f..866b9f57 100644 --- a/ext/bg/js/settings/popup-preview-frame-main.js +++ b/ext/bg/js/settings/popup-preview-frame-main.js @@ -16,11 +16,23 @@ */ /* global - * SettingsPopupPreview - * apiForwardLogsToBackend + * PopupFactory + * PopupPreviewFrame + * api */ -(() => { - apiForwardLogsToBackend(); - new SettingsPopupPreview(); +(async () => { + try { + api.forwardLogsToBackend(); + + const {frameId} = await api.frameInformationGet(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const preview = new PopupPreviewFrame(frameId, popupFactory); + await preview.prepare(); + } catch (e) { + yomichan.logError(e); + } })(); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index 8901a0c4..98630503 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -18,68 +18,78 @@ /* global * Frontend * Popup - * PopupFactory * TextSourceRange - * apiFrameInformationGet - * apiOptionsGet + * api */ -class SettingsPopupPreview { - constructor() { - this.frontend = null; - this.apiOptionsGetOld = apiOptionsGet; - this.popup = null; - this.popupSetCustomOuterCssOld = null; - this.popupShown = false; - this.themeChangeTimeout = null; - this.textSource = null; - this.optionsContext = null; +class PopupPreviewFrame { + constructor(frameId, popupFactory) { + this._frameId = frameId; + this._popupFactory = popupFactory; + this._frontend = null; + this._frontendGetOptionsContextOld = null; + this._apiOptionsGetOld = null; + this._popupSetCustomOuterCssOld = null; + 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)], - ['updateOptionsContext', ({optionsContext}) => this.updateOptionsContext(optionsContext)] + ['setText', this._setText.bind(this)], + ['setCustomCss', this._setCustomCss.bind(this)], + ['setCustomOuterCss', this._setCustomOuterCss.bind(this)], + ['updateOptionsContext', this._updateOptionsContext.bind(this)] ]); - - window.addEventListener('message', this.onMessage.bind(this), false); } - async prepare(optionsContext) { - this.optionsContext = optionsContext; + async prepare() { + window.addEventListener('message', this._onMessage.bind(this), false); // Setup events - document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false); + document.querySelector('#theme-dark-checkbox').addEventListener('change', this._onThemeDarkCheckboxChanged.bind(this), false); // Overwrite API functions - window.apiOptionsGet = this.apiOptionsGet.bind(this); + this._apiOptionsGetOld = api.optionsGet.bind(api); + api.optionsGet = this._apiOptionsGet.bind(this); // Overwrite frontend - const {frameId} = await apiFrameInformationGet(); - - const popupFactory = new PopupFactory(frameId); - await popupFactory.prepare(); + this._frontend = new Frontend( + this._frameId, + this._popupFactory, + { + allowRootFramePopupProxy: false + } + ); + this._frontendGetOptionsContextOld = this._frontend.getOptionsContext.bind(this._frontend); + this._frontend.getOptionsContext = this._getOptionsContext.bind(this); + await this._frontend.prepare(); + this._frontend.setDisabledOverride(true); + this._frontend.canClearSelection = false; + + const popup = this._frontend.popup; + popup.setChildrenSupported(false); + + this._popupSetCustomOuterCssOld = popup.setCustomOuterCss.bind(popup); + popup.setCustomOuterCss = this._popupSetCustomOuterCss.bind(this); - this.popup = popupFactory.getOrCreatePopup(); - this.popup.setChildrenSupported(false); - - this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss; - this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this); + // Update search + this._updateSearch(); + } - this.frontend = new Frontend(this.popup); - this.frontend.getOptionsContext = async () => this.optionsContext; - await this.frontend.prepare(); - this.frontend.setDisabledOverride(true); - this.frontend.canClearSelection = false; + // Private - // Update search - this.updateSearch(); + async _getOptionsContext() { + let optionsContext = this._optionsContext; + if (optionsContext === null) { + optionsContext = this._frontendGetOptionsContextOld(); + } + return optionsContext; } - async apiOptionsGet(...args) { - const options = await this.apiOptionsGetOld(...args); + async _apiOptionsGet(...args) { + const options = await this._apiOptionsGetOld(...args); options.general.enable = true; options.general.debugInfo = false; options.general.popupWidth = 400; @@ -94,9 +104,9 @@ class SettingsPopupPreview { return options; } - async popupSetCustomOuterCss(...args) { + async _popupSetCustomOuterCss(...args) { // This simulates the stylesheet priorities when injecting using the web extension API. - const result = await this.popupSetCustomOuterCssOld.call(this.popup, ...args); + const result = await this._popupSetCustomOuterCssOld(...args); const node = document.querySelector('#client-css'); if (node !== null && result !== null) { @@ -106,7 +116,7 @@ class SettingsPopupPreview { return result; } - onMessage(e) { + _onMessage(e) { if (e.origin !== this._targetOrigin) { return; } const {action, params} = e.data; @@ -116,49 +126,57 @@ class SettingsPopupPreview { handler(params); } - onThemeDarkCheckboxChanged(e) { + _onThemeDarkCheckboxChanged(e) { document.documentElement.classList.toggle('dark', e.target.checked); - if (this.themeChangeTimeout !== null) { - clearTimeout(this.themeChangeTimeout); + if (this._themeChangeTimeout !== null) { + clearTimeout(this._themeChangeTimeout); } - this.themeChangeTimeout = setTimeout(() => { - this.themeChangeTimeout = null; - this.popup.updateTheme(); + this._themeChangeTimeout = setTimeout(() => { + this._themeChangeTimeout = null; + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.updateTheme(); }, 300); } - setText(text) { + _setText({text}) { const exampleText = document.querySelector('#example-text'); if (exampleText === null) { return; } exampleText.textContent = text; - this.updateSearch(); + if (this._frontend === null) { return; } + this._updateSearch(); } - setInfoVisible(visible) { + _setInfoVisible(visible) { const node = document.querySelector('.placeholder-info'); if (node === null) { return; } node.classList.toggle('placeholder-info-visible', visible); } - setCustomCss(css) { - if (this.frontend === null) { return; } - this.popup.setCustomCss(css); + _setCustomCss({css}) { + if (this._frontend === null) { return; } + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.setCustomCss(css); } - setCustomOuterCss(css) { - if (this.frontend === null) { return; } - this.popup.setCustomOuterCss(css, false); + _setCustomOuterCss({css}) { + if (this._frontend === null) { return; } + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.setCustomOuterCss(css, false); } - async updateOptionsContext(optionsContext) { - this.optionsContext = optionsContext; - await this.frontend.updateOptions(); - await this.updateSearch(); + async _updateOptionsContext({optionsContext}) { + this._optionsContext = optionsContext; + if (this._frontend === null) { return; } + await this._frontend.updateOptions(); + await this._updateSearch(); } - async updateSearch() { + async _updateSearch() { const exampleText = document.querySelector('#example-text'); if (exampleText === null) { return; } @@ -170,17 +188,18 @@ class SettingsPopupPreview { const source = new TextSourceRange(range, range.toString(), null, null); try { - await this.frontend.setTextSource(source); + await this._frontend.setTextSource(source); } finally { source.cleanup(); } - this.textSource = source; - await this.frontend.showContentCompleted(); + this._textSource = source; + await this._frontend.showContentCompleted(); - if (this.popup.isVisibleSync()) { - this.popupShown = true; + const popup = this._frontend.popup; + if (popup !== null && popup.isVisibleSync()) { + this._popupShown = true; } - this.setInfoVisible(!this.popupShown); + this._setInfoVisible(!this._popupShown); } } diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js index fdc3dd94..d4145b76 100644 --- a/ext/bg/js/settings/popup-preview.js +++ b/ext/bg/js/settings/popup-preview.js @@ -16,69 +16,88 @@ */ /* global - * getOptionsContext * wanakana */ -function appearanceInitialize() { - let previewVisible = false; - $('#settings-popup-preview-button').on('click', () => { - if (previewVisible) { return; } - showAppearancePreview(); - previewVisible = true; - }); -} +class PopupPreviewController { + constructor(settingsController) { + this._settingsController = settingsController; + this._previewVisible = false; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + this._frame = null; + this._previewTextInput = null; + } + + prepare() { + document.querySelector('#settings-popup-preview-button').addEventListener('click', this._onShowPopupPreviewButtonClick.bind(this), false); + } + + // Private + + _onShowPopupPreviewButtonClick() { + if (this._previewVisible) { return; } + this._showAppearancePreview(); + this._previewVisible = true; + } + + _showAppearancePreview() { + const container = document.querySelector('#settings-popup-preview-container'); + const buttonContainer = document.querySelector('#settings-popup-preview-button-container'); + const settings = document.querySelector('#settings-popup-preview-settings'); + const text = document.querySelector('#settings-popup-preview-text'); + const customCss = document.querySelector('#custom-popup-css'); + const customOuterCss = document.querySelector('#custom-popup-outer-css'); + const frame = document.createElement('iframe'); + + this._previewTextInput = text; + this._frame = frame; + + wanakana.bind(text); + + frame.addEventListener('load', this._onFrameLoad.bind(this), false); + text.addEventListener('input', this._onTextChange.bind(this), false); + customCss.addEventListener('input', this._onCustomCssChange.bind(this), false); + customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false); + this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this)); + + frame.src = '/bg/settings-popup-preview.html'; + frame.id = 'settings-popup-preview-frame'; + + container.appendChild(frame); + if (buttonContainer.parentNode !== null) { + buttonContainer.parentNode.removeChild(buttonContainer); + } + settings.style.display = ''; + } + + _onFrameLoad() { + this._onOptionsContextChange(); + this._setText(this._previewTextInput.value); + } + + _onTextChange(e) { + this._setText(e.currentTarget.value); + } + + _onCustomCssChange(e) { + this._invoke('setCustomCss', {css: e.currentTarget.value}); + } + + _onCustomOuterCssChange(e) { + this._invoke('setCustomOuterCss', {css: e.currentTarget.value}); + } + + _onOptionsContextChange() { + const optionsContext = this._settingsController.getOptionsContext(); + this._invoke('updateOptionsContext', {optionsContext}); + } + + _setText(text) { + this._invoke('setText', {text}); + } -function showAppearancePreview() { - const container = $('#settings-popup-preview-container'); - const buttonContainer = $('#settings-popup-preview-button-container'); - const settings = $('#settings-popup-preview-settings'); - const text = $('#settings-popup-preview-text'); - const customCss = $('#custom-popup-css'); - const customOuterCss = $('#custom-popup-outer-css'); - - const frame = document.createElement('iframe'); - frame.src = '/bg/settings-popup-preview.html'; - frame.id = 'settings-popup-preview-frame'; - - wanakana.bind(text[0]); - - const targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); - - text.on('input', () => { - const action = 'setText'; - const params = {text: text.val()}; - frame.contentWindow.postMessage({action, params}, targetOrigin); - }); - customCss.on('input', () => { - const action = 'setCustomCss'; - const params = {css: customCss.val()}; - frame.contentWindow.postMessage({action, params}, targetOrigin); - }); - customOuterCss.on('input', () => { - const action = 'setCustomOuterCss'; - const params = {css: customOuterCss.val()}; - 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', ''); + _invoke(action, params) { + if (this._frame === null || this._frame.contentWindow === null) { return; } + this._frame.contentWindow.postMessage({action, params}, this._targetOrigin); + } } diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index bdf5a13d..2449ab44 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -17,288 +17,271 @@ /* global * ConditionsUI - * apiOptionsGetFull * conditionsClearCaches - * formWrite - * getOptionsFullMutable - * getOptionsMutable * profileConditionsDescriptor * profileConditionsDescriptorPromise - * settingsSaveOptions * utilBackgroundIsolate */ -let currentProfileIndex = 0; -let profileConditionsContainer = null; - -function getOptionsContext() { - return { - index: currentProfileIndex - }; -} - - -async function profileOptionsSetup() { - const optionsFull = await getOptionsFullMutable(); - currentProfileIndex = optionsFull.profileCurrent; - - profileOptionsSetupEventListeners(); - await profileOptionsUpdateTarget(optionsFull); -} - -function profileOptionsSetupEventListeners() { - $('#profile-target').change(onTargetProfileChanged); - $('#profile-name').change(onProfileNameChanged); - $('#profile-add').click(onProfileAdd); - $('#profile-remove').click(onProfileRemove); - $('#profile-remove-confirm').click(onProfileRemoveConfirm); - $('#profile-copy').click(onProfileCopy); - $('#profile-copy-confirm').click(onProfileCopyConfirm); - $('#profile-move-up').click(() => onProfileMove(-1)); - $('#profile-move-down').click(() => onProfileMove(1)); - $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(onProfileOptionsChanged); -} +class ProfileController { + constructor(settingsController) { + this._settingsController = settingsController; + this._conditionsContainer = null; + } -function tryGetIntegerValue(selector, min, max) { - const value = parseInt($(selector).val(), 10); - return ( - typeof value === 'number' && - Number.isFinite(value) && - Math.floor(value) === value && - value >= min && - value < max - ) ? value : null; -} + async prepare() { + $('#profile-target').change(this._onTargetProfileChanged.bind(this)); + $('#profile-name').change(this._onNameChanged.bind(this)); + $('#profile-add').click(this._onAdd.bind(this)); + $('#profile-remove').click(this._onRemove.bind(this)); + $('#profile-remove-confirm').click(this._onRemoveConfirm.bind(this)); + $('#profile-copy').click(this._onCopy.bind(this)); + $('#profile-copy-confirm').click(this._onCopyConfirm.bind(this)); + $('#profile-move-up').click(() => this._onMove(-1)); + $('#profile-move-down').click(() => this._onMove(1)); + $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(this._onInputChanged.bind(this)); + + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + this._onOptionsChanged(); + } -async function profileFormRead(optionsFull) { - const profile = optionsFull.profiles[currentProfileIndex]; + // Private - // Current profile - const index = tryGetIntegerValue('#profile-active', 0, optionsFull.profiles.length); - if (index !== null) { - optionsFull.profileCurrent = index; + async _onOptionsChanged() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + await this._formWrite(optionsFull); } - // Profile name - profile.name = $('#profile-name').val(); -} - -async function profileFormWrite(optionsFull) { - const profile = optionsFull.profiles[currentProfileIndex]; + _tryGetIntegerValue(selector, min, max) { + const value = parseInt($(selector).val(), 10); + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.floor(value) === value && + value >= min && + value < max + ) ? value : null; + } - profileOptionsPopulateSelect($('#profile-active'), optionsFull.profiles, optionsFull.profileCurrent, null); - profileOptionsPopulateSelect($('#profile-target'), optionsFull.profiles, currentProfileIndex, null); - $('#profile-remove').prop('disabled', optionsFull.profiles.length <= 1); - $('#profile-copy').prop('disabled', optionsFull.profiles.length <= 1); - $('#profile-move-up').prop('disabled', currentProfileIndex <= 0); - $('#profile-move-down').prop('disabled', currentProfileIndex >= optionsFull.profiles.length - 1); + async _formRead(optionsFull) { + const currentProfileIndex = this._settingsController.profileIndex; + const profile = optionsFull.profiles[currentProfileIndex]; - $('#profile-name').val(profile.name); + // Current profile + const index = this._tryGetIntegerValue('#profile-active', 0, optionsFull.profiles.length); + if (index !== null) { + optionsFull.profileCurrent = index; + } - if (profileConditionsContainer !== null) { - profileConditionsContainer.cleanup(); + // Profile name + profile.name = $('#profile-name').val(); } - await profileConditionsDescriptorPromise; - profileConditionsContainer = new ConditionsUI.Container( - profileConditionsDescriptor, - 'popupLevel', - profile.conditionGroups, - $('#profile-condition-groups'), - $('#profile-add-condition-group') - ); - profileConditionsContainer.save = () => { - settingsSaveOptions(); - conditionsClearCaches(profileConditionsDescriptor); - }; - profileConditionsContainer.isolate = utilBackgroundIsolate; -} + async _formWrite(optionsFull) { + const currentProfileIndex = this._settingsController.profileIndex; + const profile = optionsFull.profiles[currentProfileIndex]; -function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndices) { - select.empty(); + this._populateSelect($('#profile-active'), optionsFull.profiles, optionsFull.profileCurrent, null); + this._populateSelect($('#profile-target'), optionsFull.profiles, currentProfileIndex, null); + $('#profile-remove').prop('disabled', optionsFull.profiles.length <= 1); + $('#profile-copy').prop('disabled', optionsFull.profiles.length <= 1); + $('#profile-move-up').prop('disabled', currentProfileIndex <= 0); + $('#profile-move-down').prop('disabled', currentProfileIndex >= optionsFull.profiles.length - 1); + $('#profile-name').val(profile.name); - for (let i = 0; i < profiles.length; ++i) { - if (ignoreIndices !== null && ignoreIndices.indexOf(i) >= 0) { - continue; + if (this._conditionsContainer !== null) { + this._conditionsContainer.cleanup(); } - const profile = profiles[i]; - select.append($(`<option value="${i}">${profile.name}</option>`)); - } - select.val(`${currentValue}`); -} + await profileConditionsDescriptorPromise; + this._conditionsContainer = new ConditionsUI.Container( + profileConditionsDescriptor, + 'popupLevel', + profile.conditionGroups, + $('#profile-condition-groups'), + $('#profile-add-condition-group') + ); + this._conditionsContainer.save = () => { + this._settingsController.save(); + conditionsClearCaches(profileConditionsDescriptor); + }; + this._conditionsContainer.isolate = utilBackgroundIsolate; + } -async function profileOptionsUpdateTarget(optionsFull) { - await profileFormWrite(optionsFull); + _populateSelect(select, profiles, currentValue, ignoreIndices) { + select.empty(); - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - await formWrite(options); -} -function profileOptionsCreateCopyName(name, profiles, maxUniqueAttempts) { - let space, index, prefix, suffix; - const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name); - if (match === null) { - prefix = `${name} (Copy`; - space = ''; - index = ''; - suffix = ')'; - } else { - prefix = match[1]; - suffix = match[5]; - if (typeof match[2] === 'string') { - space = match[3]; - index = parseInt(match[4], 10) + 1; - } else { - space = ' '; - index = 2; + for (let i = 0; i < profiles.length; ++i) { + if (ignoreIndices !== null && ignoreIndices.indexOf(i) >= 0) { + continue; + } + const profile = profiles[i]; + select.append($(`<option value="${i}">${profile.name}</option>`)); } + + select.val(`${currentValue}`); } - let i = 0; - while (true) { - const newName = `${prefix}${space}${index}${suffix}`; - if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) { - return newName; - } - if (typeof index !== 'number') { - index = 2; - space = ' '; + _createCopyName(name, profiles, maxUniqueAttempts) { + let space, index, prefix, suffix; + const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name); + if (match === null) { + prefix = `${name} (Copy`; + space = ''; + index = ''; + suffix = ')'; } else { - ++index; + prefix = match[1]; + suffix = match[5]; + if (typeof match[2] === 'string') { + space = match[3]; + index = parseInt(match[4], 10) + 1; + } else { + space = ' '; + index = 2; + } } - } -} -async function onProfileOptionsChanged(e) { - if (!e.originalEvent && !e.isTrigger) { - return; + let i = 0; + while (true) { + const newName = `${prefix}${space}${index}${suffix}`; + if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) { + return newName; + } + if (typeof index !== 'number') { + index = 2; + space = ' '; + } else { + ++index; + } + } } - const optionsFull = await getOptionsFullMutable(); - await profileFormRead(optionsFull); - await settingsSaveOptions(); -} + async _onInputChanged(e) { + if (!e.originalEvent && !e.isTrigger) { + return; + } -async function onTargetProfileChanged() { - const optionsFull = await getOptionsFullMutable(); - const index = tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length); - if (index === null || currentProfileIndex === index) { - return; + const optionsFull = await this._settingsController.getOptionsFullMutable(); + await this._formRead(optionsFull); + await this._settingsController.save(); } - currentProfileIndex = index; + async _onTargetProfileChanged() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const currentProfileIndex = this._settingsController.profileIndex; + const index = this._tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length); + if (index === null || currentProfileIndex === index) { + return; + } - await profileOptionsUpdateTarget(optionsFull); + this._settingsController.profileIndex = index; + } - yomichan.trigger('modifyingProfileChange'); -} + async _onAdd() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const currentProfileIndex = this._settingsController.profileIndex; + const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]); + profile.name = this._createCopyName(profile.name, optionsFull.profiles, 100); + optionsFull.profiles.push(profile); -async function onProfileAdd() { - const optionsFull = await getOptionsFullMutable(); - const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]); - profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100); - optionsFull.profiles.push(profile); + this._settingsController.profileIndex = optionsFull.profiles.length - 1; - currentProfileIndex = optionsFull.profiles.length - 1; + await this._settingsController.save(); + } - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); + async _onRemove(e) { + if (e.shiftKey) { + return await this._onRemoveConfirm(); + } - yomichan.trigger('modifyingProfileChange'); -} + const optionsFull = await this._settingsController.getOptionsFull(); + if (optionsFull.profiles.length <= 1) { + return; + } -async function onProfileRemove(e) { - if (e.shiftKey) { - return await onProfileRemoveConfirm(); - } + const currentProfileIndex = this._settingsController.profileIndex; + const profile = optionsFull.profiles[currentProfileIndex]; - const optionsFull = await apiOptionsGetFull(); - if (optionsFull.profiles.length <= 1) { - return; + $('#profile-remove-modal-profile-name').text(profile.name); + $('#profile-remove-modal').modal('show'); } - const profile = optionsFull.profiles[currentProfileIndex]; + async _onRemoveConfirm() { + $('#profile-remove-modal').modal('hide'); - $('#profile-remove-modal-profile-name').text(profile.name); - $('#profile-remove-modal').modal('show'); -} + const optionsFull = await this._settingsController.getOptionsFullMutable(); + if (optionsFull.profiles.length <= 1) { + return; + } -async function onProfileRemoveConfirm() { - $('#profile-remove-modal').modal('hide'); + const currentProfileIndex = this._settingsController.profileIndex; + optionsFull.profiles.splice(currentProfileIndex, 1); - const optionsFull = await getOptionsFullMutable(); - if (optionsFull.profiles.length <= 1) { - return; - } + if (currentProfileIndex >= optionsFull.profiles.length) { + this._settingsController.profileIndex = optionsFull.profiles.length - 1; + } - optionsFull.profiles.splice(currentProfileIndex, 1); + if (optionsFull.profileCurrent >= optionsFull.profiles.length) { + optionsFull.profileCurrent = optionsFull.profiles.length - 1; + } - if (currentProfileIndex >= optionsFull.profiles.length) { - --currentProfileIndex; + await this._settingsController.save(); } - if (optionsFull.profileCurrent >= optionsFull.profiles.length) { - optionsFull.profileCurrent = optionsFull.profiles.length - 1; + _onNameChanged() { + const currentProfileIndex = this._settingsController.profileIndex; + $('#profile-active, #profile-target').find(`[value="${currentProfileIndex}"]`).text(this.value); } - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); - - yomichan.trigger('modifyingProfileChange'); -} + async _onMove(offset) { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const currentProfileIndex = this._settingsController.profileIndex; + const index = currentProfileIndex + offset; + if (index < 0 || index >= optionsFull.profiles.length) { + return; + } -function onProfileNameChanged() { - $('#profile-active, #profile-target').find(`[value="${currentProfileIndex}"]`).text(this.value); -} + const profile = optionsFull.profiles[currentProfileIndex]; + optionsFull.profiles.splice(currentProfileIndex, 1); + optionsFull.profiles.splice(index, 0, profile); -async function onProfileMove(offset) { - const optionsFull = await getOptionsFullMutable(); - const index = currentProfileIndex + offset; - if (index < 0 || index >= optionsFull.profiles.length) { - return; - } + if (optionsFull.profileCurrent === currentProfileIndex) { + optionsFull.profileCurrent = index; + } - const profile = optionsFull.profiles[currentProfileIndex]; - optionsFull.profiles.splice(currentProfileIndex, 1); - optionsFull.profiles.splice(index, 0, profile); + this._settingsController.profileIndex = index; - if (optionsFull.profileCurrent === currentProfileIndex) { - optionsFull.profileCurrent = index; + await this._settingsController.save(); } - currentProfileIndex = index; - - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); - - yomichan.trigger('modifyingProfileChange'); -} + async _onCopy() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + if (optionsFull.profiles.length <= 1) { + return; + } -async function onProfileCopy() { - const optionsFull = await apiOptionsGetFull(); - if (optionsFull.profiles.length <= 1) { - return; + const currentProfileIndex = this._settingsController.profileIndex; + this._populateSelect($('#profile-copy-source'), optionsFull.profiles, currentProfileIndex === 0 ? 1 : 0, [currentProfileIndex]); + $('#profile-copy-modal').modal('show'); } - profileOptionsPopulateSelect($('#profile-copy-source'), optionsFull.profiles, currentProfileIndex === 0 ? 1 : 0, [currentProfileIndex]); - $('#profile-copy-modal').modal('show'); -} - -async function onProfileCopyConfirm() { - $('#profile-copy-modal').modal('hide'); + async _onCopyConfirm() { + $('#profile-copy-modal').modal('hide'); - const optionsFull = await getOptionsFullMutable(); - const index = tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length); - if (index === null || index === currentProfileIndex) { - return; - } + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const index = this._tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length); + const currentProfileIndex = this._settingsController.profileIndex; + if (index === null || index === currentProfileIndex) { + return; + } - const profileOptions = utilBackgroundIsolate(optionsFull.profiles[index].options); - optionsFull.profiles[currentProfileIndex].options = profileOptions; + const profileOptions = utilBackgroundIsolate(optionsFull.profiles[index].options); + optionsFull.profiles[currentProfileIndex].options = profileOptions; - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); + await this._settingsController.save(); + } } diff --git a/ext/bg/js/settings/settings-controller.js b/ext/bg/js/settings/settings-controller.js new file mode 100644 index 00000000..87dea408 --- /dev/null +++ b/ext/bg/js/settings/settings-controller.js @@ -0,0 +1,150 @@ +/* + * 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 + * api + * utilBackend + * utilBackgroundIsolate + */ + +class SettingsController extends EventDispatcher { + constructor(profileIndex=0) { + super(); + this._profileIndex = profileIndex; + this._source = yomichan.generateId(16); + } + + get source() { + return this._source; + } + + get profileIndex() { + return this._profileIndex; + } + + set profileIndex(value) { + if (this._profileIndex === value) { return; } + this._setProfileIndex(value); + } + + prepare() { + yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); + } + + async save() { + await api.optionsSave(this._source); + } + + async getOptions() { + const optionsContext = this.getOptionsContext(); + return await api.optionsGet(optionsContext); + } + + async getOptionsFull() { + return await api.optionsGetFull(); + } + + async getOptionsMutable() { + const optionsContext = this.getOptionsContext(); + return utilBackend().getOptions(utilBackgroundIsolate(optionsContext)); + } + + async getOptionsFullMutable() { + return utilBackend().getFullOptions(); + } + + async setAllSettings(value) { + const profileIndex = value.profileCurrent; + await api.setAllSettings(value, this._source); + this._setProfileIndex(profileIndex); + } + + async getSettings(targets) { + return await this._getSettings(targets, {}); + } + + async getGlobalSettings(targets) { + return await this._getSettings(targets, {scope: 'global'}); + } + + async getProfileSettings(targets) { + return await this._getSettings(targets, {scope: 'profile'}); + } + + async modifySettings(targets) { + return await this._modifySettings(targets, {}); + } + + async modifyGlobalSettings(targets) { + return await this._modifySettings(targets, {scope: 'global'}); + } + + async modifyProfileSettings(targets) { + return await this._modifySettings(targets, {scope: 'profile'}); + } + + async setGlobalSetting(path, value) { + return await this.modifyGlobalSettings([{action: 'set', path, value}]); + } + + async setProfileSetting(path, value) { + return await this.modifyProfileSettings([{action: 'set', path, value}]); + } + + getOptionsContext() { + return {index: this._profileIndex}; + } + + // Private + + _setProfileIndex(value) { + this._profileIndex = value; + this.trigger('optionsContextChanged'); + this._onOptionsUpdatedInternal(); + } + + _onOptionsUpdated({source}) { + if (source === this._source) { return; } + this._onOptionsUpdatedInternal(); + } + + async _onOptionsUpdatedInternal() { + const optionsContext = this.getOptionsContext(); + const options = await this.getOptions(); + this.trigger('optionsChanged', {options, optionsContext}); + } + + _setupTargets(targets, extraFields) { + return targets.map((target) => { + target = Object.assign({}, extraFields, target); + if (target.scope === 'profile') { + target.optionsContext = this.getOptionsContext(); + } + return target; + }); + } + + async _getSettings(targets, extraFields) { + targets = this._setupTargets(targets, extraFields); + return await api.getSettings(targets); + } + + async _modifySettings(targets, extraFields) { + targets = this._setupTargets(targets, extraFields); + return await api.modifySettings(targets, this._source); + } +} diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js index d754a109..24c6d7ef 100644 --- a/ext/bg/js/settings/storage.js +++ b/ext/bg/js/settings/storage.js @@ -15,126 +15,117 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/* global - * apiGetEnvironmentInfo - */ - -function storageBytesToLabeledString(size) { - const base = 1000; - const labels = [' bytes', 'KB', 'MB', 'GB']; - let labelIndex = 0; - while (size >= base) { - size /= base; - ++labelIndex; +class StorageController { + constructor() { + this._mostRecentStorageEstimate = null; + this._storageEstimateFailed = false; + this._isUpdating = false; } - const label = labelIndex === 0 ? `${size}` : size.toFixed(1); - return `${label}${labels[labelIndex]}`; -} -async function storageEstimate() { - try { - return (storageEstimate.mostRecent = await navigator.storage.estimate()); - } catch (e) { - // NOP + prepare() { + this._preparePersistentStorage(); + this.updateStats(); + document.querySelector('#storage-refresh').addEventListener('click', this.updateStats.bind(this), false); } - return null; -} -storageEstimate.mostRecent = null; - -async function isStoragePeristent() { - try { - return await navigator.storage.persisted(); - } catch (e) { - // NOP - } - return false; -} - -async function storageInfoInitialize() { - storagePersistInitialize(); - const {browser, platform} = await apiGetEnvironmentInfo(); - document.documentElement.dataset.browser = browser; - document.documentElement.dataset.operatingSystem = platform.os; - - await storageShowInfo(); - document.querySelector('#storage-refresh').addEventListener('click', storageShowInfo, false); -} - -async function storageUpdateStats() { - storageUpdateStats.isUpdating = true; - - const estimate = await storageEstimate(); - const valid = (estimate !== null); - - if (valid) { - // Firefox reports usage as 0 when persistent storage is enabled. - const finite = (estimate.usage > 0 || !(await isStoragePeristent())); - if (finite) { - document.querySelector('#storage-usage').textContent = storageBytesToLabeledString(estimate.usage); - document.querySelector('#storage-quota').textContent = storageBytesToLabeledString(estimate.quota); + async updateStats() { + try { + this._isUpdating = true; + + const estimate = await this._storageEstimate(); + const valid = (estimate !== null); + + if (valid) { + // Firefox reports usage as 0 when persistent storage is enabled. + const finite = (estimate.usage > 0 || !(await this._isStoragePeristent())); + if (finite) { + document.querySelector('#storage-usage').textContent = this._bytesToLabeledString(estimate.usage); + document.querySelector('#storage-quota').textContent = this._bytesToLabeledString(estimate.quota); + } + document.querySelector('#storage-use-finite').classList.toggle('storage-hidden', !finite); + document.querySelector('#storage-use-infinite').classList.toggle('storage-hidden', finite); + } + + document.querySelector('#storage-use').classList.toggle('storage-hidden', !valid); + document.querySelector('#storage-error').classList.toggle('storage-hidden', valid); + + return valid; + } finally { + this._isUpdating = false; } - document.querySelector('#storage-use-finite').classList.toggle('storage-hidden', !finite); - document.querySelector('#storage-use-infinite').classList.toggle('storage-hidden', finite); } - storageUpdateStats.isUpdating = false; - return valid; -} -storageUpdateStats.isUpdating = false; - -async function storageShowInfo() { - storageSpinnerShow(true); - - const valid = await storageUpdateStats(); - document.querySelector('#storage-use').classList.toggle('storage-hidden', !valid); - document.querySelector('#storage-error').classList.toggle('storage-hidden', valid); + // Private - storageSpinnerShow(false); -} + async _preparePersistentStorage() { + if (!(navigator.storage && navigator.storage.persist)) { + // Not supported + return; + } -function storageSpinnerShow(show) { - const spinner = $('#storage-spinner'); - if (show) { - spinner.show(); - } else { - spinner.hide(); + const info = document.querySelector('#storage-persist-info'); + const button = document.querySelector('#storage-persist-button'); + const checkbox = document.querySelector('#storage-persist-button-checkbox'); + + info.classList.remove('storage-hidden'); + button.classList.remove('storage-hidden'); + + let persisted = await this._isStoragePeristent(); + checkbox.checked = persisted; + + button.addEventListener('click', async () => { + if (persisted) { + return; + } + let result = false; + try { + result = await navigator.storage.persist(); + } catch (e) { + // NOP + } + + if (result) { + persisted = true; + checkbox.checked = true; + this.updateStats(); + } else { + document.querySelector('.storage-persist-fail-warning').classList.remove('storage-hidden'); + } + }, false); } -} -async function storagePersistInitialize() { - if (!(navigator.storage && navigator.storage.persist)) { - // Not supported - return; + async _storageEstimate() { + if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) { + return null; + } + try { + const value = await navigator.storage.estimate(); + this._mostRecentStorageEstimate = value; + return value; + } catch (e) { + this._storageEstimateFailed = true; + } + return null; } - const info = document.querySelector('#storage-persist-info'); - const button = document.querySelector('#storage-persist-button'); - const checkbox = document.querySelector('#storage-persist-button-checkbox'); - - info.classList.remove('storage-hidden'); - button.classList.remove('storage-hidden'); - - let persisted = await isStoragePeristent(); - checkbox.checked = persisted; - - button.addEventListener('click', async () => { - if (persisted) { - return; + _bytesToLabeledString(size) { + const base = 1000; + const labels = [' bytes', 'KB', 'MB', 'GB']; + let labelIndex = 0; + while (size >= base) { + size /= base; + ++labelIndex; } - let result = false; + const label = labelIndex === 0 ? `${size}` : size.toFixed(1); + return `${label}${labels[labelIndex]}`; + } + + async _isStoragePeristent() { try { - result = await navigator.storage.persist(); + return await navigator.storage.persisted(); } catch (e) { // NOP } - - if (result) { - persisted = true; - checkbox.checked = true; - storageShowInfo(); - } else { - $('.storage-persist-fail-warning').removeClass('storage-hidden'); - } - }, false); + return false; + } } diff --git a/ext/bg/js/template-renderer.js b/ext/bg/js/template-renderer.js new file mode 100644 index 00000000..f4b50c3d --- /dev/null +++ b/ext/bg/js/template-renderer.js @@ -0,0 +1,202 @@ +/* + * 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 + * Handlebars + * jp + */ + +class TemplateRenderer { + constructor() { + this._cache = new Map(); + this._cacheMaxSize = 5; + this._helpersRegistered = false; + } + + async render(template, data) { + if (!this._helpersRegistered) { + this._registerHelpers(); + this._helpersRegistered = true; + } + + const cache = this._cache; + let instance = cache.get(template); + if (typeof instance === 'undefined') { + this._updateCacheSize(this._cacheMaxSize - 1); + instance = Handlebars.compile(template); + cache.set(template, instance); + } + + return instance(data).trim(); + } + + // Private + + _updateCacheSize(maxSize) { + const cache = this._cache; + let removeCount = cache.size - maxSize; + if (removeCount <= 0) { return; } + + for (const key of cache.keys()) { + cache.delete(key); + if (--removeCount <= 0) { break; } + } + } + + _registerHelpers() { + Handlebars.partials = Handlebars.templates; + + const helpers = [ + ['dumpObject', this._dumpObject.bind(this)], + ['furigana', this._furigana.bind(this)], + ['furiganaPlain', this._furiganaPlain.bind(this)], + ['kanjiLinks', this._kanjiLinks.bind(this)], + ['multiLine', this._multiLine.bind(this)], + ['sanitizeCssClass', this._sanitizeCssClass.bind(this)], + ['regexReplace', this._regexReplace.bind(this)], + ['regexMatch', this._regexMatch.bind(this)], + ['mergeTags', this._mergeTags.bind(this)] + ]; + + for (const [name, helper] of helpers) { + Handlebars.registerHelper(name, helper); + } + } + + _escape(text) { + return Handlebars.Utils.escapeExpression(text); + } + + _dumpObject(options) { + const dump = JSON.stringify(options.fn(this), null, 4); + return this._escape(dump); + } + + _furigana(options) { + const definition = options.fn(this); + const segs = jp.distributeFurigana(definition.expression, definition.reading); + + let result = ''; + for (const seg of segs) { + if (seg.furigana) { + result += `<ruby>${seg.text}<rt>${seg.furigana}</rt></ruby>`; + } else { + result += seg.text; + } + } + + return result; + } + + _furiganaPlain(options) { + const definition = options.fn(this); + const segs = jp.distributeFurigana(definition.expression, definition.reading); + + let result = ''; + for (const seg of segs) { + if (seg.furigana) { + result += ` ${seg.text}[${seg.furigana}]`; + } else { + result += seg.text; + } + } + + return result.trimLeft(); + } + + _kanjiLinks(options) { + let result = ''; + for (const c of options.fn(this)) { + if (jp.isCodePointKanji(c.codePointAt(0))) { + result += `<a href="#" class="kanji-link">${c}</a>`; + } else { + result += c; + } + } + + return result; + } + + _multiLine(options) { + return options.fn(this).split('\n').join('<br>'); + } + + _sanitizeCssClass(options) { + return options.fn(this).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_'); + } + + _regexReplace(...args) { + // Usage: + // {{#regexReplace regex string [flags]}}content{{/regexReplace}} + // regex: regular expression string + // string: string to replace + // flags: optional flags for regular expression + // e.g. "i" for case-insensitive, "g" for replace all + let value = args[args.length - 1].fn(this); + if (args.length >= 3) { + try { + const flags = args.length > 3 ? args[2] : 'g'; + const regex = new RegExp(args[0], flags); + value = value.replace(regex, args[1]); + } catch (e) { + return `${e}`; + } + } + return value; + } + + _regexMatch(...args) { + // Usage: + // {{#regexMatch regex [flags]}}content{{/regexMatch}} + // regex: regular expression string + // flags: optional flags for regular expression + // e.g. "i" for case-insensitive, "g" for match all + let value = args[args.length - 1].fn(this); + if (args.length >= 2) { + try { + const flags = args.length > 2 ? args[1] : ''; + const regex = new RegExp(args[0], flags); + const parts = []; + value.replace(regex, (g0) => parts.push(g0)); + value = parts.join(''); + } catch (e) { + return `${e}`; + } + } + return value; + } + + _mergeTags(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(', '); + } +} diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 8f86e47a..edc19c6e 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -65,12 +65,3 @@ function utilBackend() { } return backend; } - -function utilReadFileArrayBuffer(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsArrayBuffer(file); - }); -} diff --git a/ext/bg/legal.html b/ext/bg/legal.html index 1ee9a28c..8b4fe513 100644 --- a/ext/bg/legal.html +++ b/ext/bg/legal.html @@ -1,22 +1,27 @@ <!DOCTYPE html> <html lang="en"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width,initial-scale=1" /> - <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"> - <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> - <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"> - </head> - <body> - <div class="container"> - <h3>Yomichan License</h3> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <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"> + <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> + <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"> + <style> +pre { + white-space: pre-line; +} + </style> +</head> +<body><div class="container"> + +<h3>Yomichan License</h3> <pre> Copyright (C) 2016-2020 Yomichan Authors @@ -33,21 +38,138 @@ 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/>. </pre> - <h3>EDRDG License</h3> + +<h3>EDRDG License</h3> <pre> -This package uses the <a href="https://www.edrdg.org/jmdict/edict.html">EDICT</a> and <a href="https://www.edrdg.org/wiki/index.php/KANJIDIC_Project">KANJIDIC</a> dictionary files. These files are -the property of the <a href="https://www.edrdg.org/">Electronic Dictionary Research and Development Group</a>, -and are used in conformance with the Group's <a href="https://www.edrdg.org/edrdg/licence.html">licence</a>. +This package uses the <a href="https://www.edrdg.org/jmdict/edict.html" target="_blank" rel="noopener">EDICT</a> and <a href="https://www.edrdg.org/wiki/index.php/KANJIDIC_Project" target="_blank" rel="noopener">KANJIDIC</a> dictionary files. These files are +the property of the <a href="https://www.edrdg.org/" target="_blank" rel="noopener">Electronic Dictionary Research and Development Group</a>, +and are used in conformance with the Group's <a href="https://www.edrdg.org/edrdg/licence.html" target="_blank" rel="noopener">licence</a>. </pre> - <h3>Third-Party Software Licenses</h3> - <ul> - <li><a href="https://github.com/twbs/bootstrap/blob/v3.3.7/LICENSE" target="_blank" rel="noopener">Bootstrap v3.3.7</a></li> - <li><a href="https://github.com/wycats/handlebars.js/blob/v4.0.6/LICENSE" target="_blank" rel="noopener">Handlebars v4.0.6</a></li> - <li><a href="https://github.com/jquery/jquery/blob/3.2.1/LICENSE.txt" target="_blank" rel="noopener">jQuery v3.2.1</a></li> - <li><a href="https://github.com/Stuk/jszip/blob/v3.1.3/LICENSE.markdown" target="_blank" rel="noopener">JSZip v3.1.3</a></li> - <li><a href="https://github.com/WaniKani/WanaKana/blob/4.0.2/LICENSE" target="_blank" rel="noopener">WanaKana v4.0.2</a></li> - </ul> - </div> - </div> -</body> + +<h3>Third-Party Software Licenses</h3> + +<h4><a href="https://github.com/twbs/bootstrap/blob/v3.3.7/LICENSE" target="_blank" rel="noopener">Bootstrap v3.3.7</a></h4> +<pre> +The MIT License (MIT) + +Copyright (c) 2011-2016 Twitter, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + +<h4><a href="https://github.com/wycats/handlebars.js/blob/v4.0.6/LICENSE" target="_blank" rel="noopener">Handlebars v4.0.6</a></h4> +<pre> +Copyright (C) 2011-2016 by Yehuda Katz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + +<h4><a href="https://github.com/jquery/jquery/blob/3.2.1/LICENSE.txt" target="_blank" rel="noopener">jQuery v3.2.1</a></h4> +<pre> +Copyright JS Foundation and other contributors, https://js.foundation/ + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/jquery/jquery + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +All files located in the node_modules and external directories are +externally maintained libraries used by this software which have their +own licenses; we recommend you read them, as their terms may differ from +the terms above. +</pre> + +<h4><a href="https://github.com/Stuk/jszip/blob/v3.1.3/LICENSE.markdown" target="_blank" rel="noopener">JSZip v3.1.3</a></h4> +<pre> +Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + +<h4><a href="https://github.com/WaniKani/WanaKana/blob/4.0.2/LICENSE" target="_blank" rel="noopener">WanaKana v4.0.2</a></h4> +<pre> +The MIT License (MIT) + +Copyright (c) 2013 WaniKani Community Github + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + +</div></body> </html> diff --git a/ext/bg/search.html b/ext/bg/search.html index f3f156d8..4a28dd88 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -54,8 +54,8 @@ <hr> <div id="navigation-header" class="navigation-header" hidden><div class="navigation-header-actions"> - <button class="action-button action-previous"><img src="/mixed/img/source-term.svg" class="icon-image" title="Source term (Alt + B)" alt></button> - <button class="action-button action-next"><img src="/mixed/img/source-term.svg" class="icon-image" title="Next term (Alt + F)" alt></button> + <button class="action-button action-previous" data-icon="source-term" title="Source term (Alt + B)"></button> + <button class="action-button action-next" data-icon="source-term" title="Next term (Alt + F)"></button> </div></div><div class="navigation-header-spacer"></div> <div id="content"></div> @@ -71,6 +71,7 @@ <script src="/mixed/lib/wanakana.min.js"></script> <script src="/mixed/js/core.js"></script> + <script src="/mixed/js/comm.js"></script> <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/api.js"></script> <script src="/mixed/js/japanese.js"></script> @@ -78,6 +79,7 @@ <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> <script src="/fg/js/document.js"></script> + <script src="/fg/js/dom-text-scanner.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> diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html index 2f0b841b..75bf06c8 100644 --- a/ext/bg/settings-popup-preview.html +++ b/ext/bg/settings-popup-preview.html @@ -119,17 +119,19 @@ </div></div></div> <script src="/mixed/js/core.js"></script> + <script src="/mixed/js/comm.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/dom-text-scanner.js"></script> <script src="/fg/js/popup.js"></script> <script src="/fg/js/source.js"></script> <script src="/fg/js/popup-factory.js"></script> <script src="/fg/js/frontend.js"></script> + <script src="/fg/js/frame-offset-forwarder.js"></script> <script src="/bg/js/settings/popup-preview-frame.js"></script> <script src="/bg/js/settings/popup-preview-frame-main.js"></script> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 3ce91f12..51cb14e7 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -135,60 +135,68 @@ <h3>General Options</h3> <div class="checkbox"> - <label><input type="checkbox" id="enable"> Enable content scanning</label> + <label><input type="checkbox" id="enable" data-setting="general.enable"> Enable content scanning</label> </div> - <div class="checkbox" data-hide-for-browser="firefox-mobile"> + <div class="checkbox ignore-form-changes" data-hide-for-browser="firefox-mobile"> <label><input type="checkbox" id="enable-clipboard-popups"> Enable native popups when copying Japanese text</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="show-usage-guide"> Show usage guide on startup</label> + <label><input type="checkbox" id="show-usage-guide" data-setting="general.showGuide"> Show usage guide on startup</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="compact-tags"> Compact tags</label> + <label><input type="checkbox" id="compact-tags" data-setting="general.compactTags"> Compact tags</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="compact-glossaries"> Compact glossaries</label> + <label><input type="checkbox" id="compact-glossaries" data-setting="general.compactGlossaries"> Compact glossaries</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="show-advanced-options"> Show advanced options</label> + <label><input type="checkbox" id="show-advanced-options" data-setting="general.showAdvanced" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-show-advanced"> Show advanced options</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="popup-scale-relative-to-page-zoom"> Change popup size relative to page zoom level</label> + <label><input type="checkbox" id="popup-scale-relative-to-page-zoom" data-setting="general.popupScaleRelativeToPageZoom"> Change popup size relative to page zoom level</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="popup-scale-relative-to-visual-viewport"> Change popup size relative to page viewport</label> + <label><input type="checkbox" id="popup-scale-relative-to-visual-viewport" data-setting="general.popupScaleRelativeToVisualViewport"> Change popup size relative to page viewport</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-pitch-accent-downstep-notation"> Show downstep notation for pitch accents</label> + <label><input type="checkbox" id="show-pitch-accent-downstep-notation" data-setting="general.showPitchAccentDownstepNotation"> Show downstep notation for pitch accents</label> </div> <div class="checkbox options-position"> - <label><input type="checkbox" id="show-pitch-accent-position-notation"> Show position notation for pitch accents</label> + <label><input type="checkbox" id="show-pitch-accent-position-notation" data-setting="general.showPitchAccentPositionNotation"> Show position notation for pitch accents</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-pitch-accent-graph"> Show graph for pitch accents</label> + <label><input type="checkbox" id="show-pitch-accent-graph" data-setting="general.showPitchAccentGraph"> Show graph for pitch accents</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-iframe-popups-in-root-frame"> Show iframe popups in root frame</label> + <label><input type="checkbox" id="show-iframe-popups-in-root-frame" data-setting="general.showIframePopupsInRootFrame"> Show iframe popups in root frame</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-debug-info"> Show debug information</label> + <label><input type="checkbox" data-setting="general.useSecurePopupFrameUrl"> Use secure popup frame URL</label> + </div> + + <div class="checkbox options-advanced"> + <label><input type="checkbox" data-setting="general.usePopupShadowDom"> Use shadow DOM container for popup</label> + </div> + + <div class="checkbox options-advanced"> + <label><input type="checkbox" id="show-debug-info" data-setting="general.debugInfo" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-debug-info"> Show debug information</label> </div> <div class="form-group"> <label for="result-output-mode">Result grouping</label> - <select class="form-control" id="result-output-mode"> + <select class="form-control" id="result-output-mode" data-setting="general.resultOutputMode" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-result-output-mode"> <option value="group">Group results by term-reading pairs</option> <option value="merge">Group results by main dictionary entry</option> <option value="split">Split definitions to their own results</option> @@ -197,7 +205,7 @@ <div class="form-group"> <label for="popup-display-mode">Popup display mode</label> - <select class="form-control" id="popup-display-mode"> + <select class="form-control" id="popup-display-mode" data-setting="general.popupDisplayMode"> <option value="default">Default</option> <option value="full-width">Full width</option> </select> @@ -205,26 +213,26 @@ <div class="form-group"> <label for="popup-scaling-factor">Popup size multiplier</label> - <input type="number" min="0" id="popup-scaling-factor" class="form-control"> + <input type="number" min="0" id="popup-scaling-factor" data-setting="general.popupScalingFactor" class="form-control"> </div> <div class="form-group options-advanced"> <label for="max-displayed-results">Maximum displayed results</label> - <input type="number" min="1" id="max-displayed-results" class="form-control"> + <input type="number" min="1" id="max-displayed-results" class="form-control" data-setting="general.maxResults"> </div> <div class="form-group"> <div class="row"> <div class="col-xs-6"> <label for="popup-horizontal-text-position">Popup position for horizontal text</label> - <select class="form-control" id="popup-horizontal-text-position"> + <select class="form-control" id="popup-horizontal-text-position" data-setting="general.popupHorizontalTextPosition"> <option value="below">Below text</option> <option value="above">Above text</option> </select> </div> <div class="col-xs-6"> <label for="popup-vertical-text-position">Popup position for vertical text</label> - <select class="form-control" id="popup-vertical-text-position"> + <select class="form-control" id="popup-vertical-text-position" data-setting="general.popupVerticalTextPosition"> <option value="default">Same as for horizontal text</option> <option value="before">Before text reading direction</option> <option value="after">After text reading direction</option> @@ -239,11 +247,11 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-width">Popup width <span class="label-light">(in pixels)</span></label> - <input type="number" min="1" id="popup-width" class="form-control"> + <input type="number" min="1" id="popup-width" class="form-control" data-setting="general.popupWidth"> </div> <div class="col-xs-6"> <label for="popup-height">Popup height <span class="label-light">(in pixels)</span></label> - <input type="number" min="1" id="popup-height" class="form-control"> + <input type="number" min="1" id="popup-height" class="form-control" data-setting="general.popupHeight"> </div> </div> </div> @@ -252,11 +260,11 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-horizontal-offset">Horizontal popup offset <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-horizontal-offset" class="form-control"> + <input type="number" min="0" id="popup-horizontal-offset" class="form-control" data-setting="general.popupHorizontalOffset"> </div> <div class="col-xs-6"> <label for="popup-vertical-offset">Vertical popup offset <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-vertical-offset" class="form-control"> + <input type="number" min="0" id="popup-vertical-offset" class="form-control" data-setting="general.popupVerticalOffset"> </div> </div> </div> @@ -265,11 +273,11 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-horizontal-offset2">Horizontal popup offset for vertical text <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-horizontal-offset2" class="form-control"> + <input type="number" min="0" id="popup-horizontal-offset2" class="form-control" data-setting="general.popupHorizontalOffset2"> </div> <div class="col-xs-6"> <label for="popup-vertical-offset2">Vertical popup offset for vertical text <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-vertical-offset2" class="form-control"> + <input type="number" min="0" id="popup-vertical-offset2" class="form-control" data-setting="general.popupVerticalOffset2"> </div> </div> </div> @@ -278,14 +286,14 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-theme">Popup theme</label> - <select class="form-control" id="popup-theme"> + <select class="form-control" id="popup-theme" data-setting="general.popupTheme"> <option value="default">Light</option> <option value="dark">Dark</option> </select> </div> <div class="col-xs-6"> <label for="popup-outer-theme">Popup shadow theme</label> - <select class="form-control" id="popup-outer-theme"> + <select class="form-control" id="popup-outer-theme" data-setting="general.popupOuterTheme"> <option value="auto">Auto-detect</option> <option value="default">Light</option> <option value="dark">Dark</option> @@ -298,11 +306,11 @@ <div class="row"> <div class="col-xs-6"> <label for="custom-popup-css">Custom popup CSS</label> - <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-css" class="form-control"></textarea></div> + <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-css" class="form-control" data-setting="general.customPopupCss"></textarea></div> </div> <div class="col-xs-6"> <label for="custom-popup-outer-css">Custom popup outer CSS</label> - <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-outer-css" class="form-control" placeholder="iframe.yomichan-float { /*styles*/ }"></textarea></div> + <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-outer-css" class="form-control" data-setting="general.customPopupOuterCss" placeholder="iframe.yomichan-float { /*styles*/ }"></textarea></div> </div> </div> </div> @@ -324,22 +332,22 @@ <h3>Audio Options</h3> <div class="checkbox"> - <label><input type="checkbox" id="audio-playback-enabled"> Enable audio playback in search results</label> + <label><input type="checkbox" id="audio-playback-enabled" data-setting="audio.enabled"> Enable audio playback in search results</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="auto-play-audio"> Play audio automatically</label> + <label><input type="checkbox" id="auto-play-audio" data-setting="audio.autoPlay"> Play audio automatically</label> </div> <div class="form-group"> <label for="audio-playback-volume">Audio playback volume <span class="label-light">(percent)</span></label> - <input type="number" min="0" max="100" id="audio-playback-volume" class="form-control"> + <input type="number" min="0" max="100" id="audio-playback-volume" class="form-control" data-setting="audio.volume"> </div> <div class="form-group" id="text-to-speech-voice-container" hidden> <label for="text-to-speech-voice">Text-to-speech voice</label> <div class="input-group"> - <select class="form-control" id="text-to-speech-voice"></select> + <select class="form-control" id="text-to-speech-voice" data-setting="audio.textToSpeechVoice"></select> <div class="input-group-btn"> <button class="btn btn-default" id="text-to-speech-voice-test" title="Test voice" data-speech-text="よみちゃん"><span class="glyphicon glyphicon-volume-up"></span></button> </div> @@ -348,7 +356,7 @@ <div class="form-group options-advanced"> <label for="audio-custom-source">Custom audio source <span class="label-light">(URL)</span></label> - <input type="text" id="audio-custom-source" class="form-control" placeholder="Example: http://localhost/audio.mp3?expression={expression}&reading={reading}"> + <input type="text" id="audio-custom-source" class="form-control" data-setting="audio.customSourceUrl" placeholder="Example: http://localhost/audio.mp3?expression={expression}&reading={reading}"> </div> <div class="form-group ignore-form-changes"> @@ -377,42 +385,46 @@ <h3>Scanning Options</h3> <div class="checkbox"> - <label><input type="checkbox" id="middle-mouse-button-scan"> Middle mouse button scans</label> + <label><input type="checkbox" id="middle-mouse-button-scan" data-setting="scanning.middleMouse"> Middle mouse button scans</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="touch-input-enabled"> Touch input enabled</label> + <label><input type="checkbox" id="touch-input-enabled" data-setting="scanning.touchInputEnabled"> Touch input enabled</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="select-matched-text"> Select matched text</label> + <label><input type="checkbox" id="select-matched-text" data-setting="scanning.selectText"> Select matched text</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="search-alphanumeric"> Search alphanumeric text</label> + <label><input type="checkbox" id="search-alphanumeric" data-setting="scanning.alphanumeric"> Search alphanumeric text</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="auto-hide-results"> Automatically hide results</label> + <label><input type="checkbox" id="auto-hide-results" data-setting="scanning.autoHideResults"> Automatically hide results</label> + </div> + + <div class="checkbox"> + <label><input type="checkbox" id="layout-aware-scan" data-setting="scanning.layoutAwareScan"> Layout-aware scan</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="deep-dom-scan"> Deep DOM scan</label> + <label><input type="checkbox" id="deep-dom-scan" data-setting="scanning.deepDomScan"> Deep DOM scan</label> </div> <div class="form-group options-advanced"> <label for="scan-delay">Scan delay <span class="label-light">(in milliseconds)</span></label> - <input type="number" min="0" id="scan-delay" class="form-control"> + <input type="number" min="0" id="scan-delay" class="form-control" data-setting="scanning.delay"> </div> <div class="form-group options-advanced"> <label for="scan-length">Scan length <span class="label-light">(in characters)</span></label> - <input type="number" min="1" step="1" id="scan-length" class="form-control"> + <input type="number" min="1" step="1" id="scan-length" class="form-control" data-setting="scanning.length"> </div> <div class="form-group"> <label for="scan-modifier-key">Scan modifier key</label> - <select class="form-control" id="scan-modifier-key"></select> + <select class="form-control" id="scan-modifier-key" data-setting="scanning.modifier"></select> </div> </div> @@ -447,7 +459,7 @@ <div class="form-group"> <label for="translation-convert-half-width-characters">Convert half width characters to full width <span class="label-light">(ヨミチャン → ヨミチャン)</span></label> - <select class="form-control" id="translation-convert-half-width-characters"> + <select class="form-control" id="translation-convert-half-width-characters" data-setting="translation.convertHalfWidthCharacters"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -456,7 +468,7 @@ <div class="form-group"> <label for="translation-convert-numeric-characters">Convert numeric characters to full width <span class="label-light">(1234 → 1234)</span></label> - <select class="form-control" id="translation-convert-numeric-characters"> + <select class="form-control" id="translation-convert-numeric-characters" data-setting="translation.convertNumericCharacters"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -465,7 +477,7 @@ <div class="form-group"> <label for="translation-convert-alphabetic-characters">Convert alphabetic characters to hiragana <span class="label-light">(yomichan → よみちゃん)</span></label> - <select class="form-control" id="translation-convert-alphabetic-characters"> + <select class="form-control" id="translation-convert-alphabetic-characters" data-setting="translation.convertAlphabeticCharacters"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -474,7 +486,7 @@ <div class="form-group"> <label for="translation-convert-hiragana-to-katakana">Convert hiragana to katakana <span class="label-light">(よみちゃん → ヨミチャン)</span></label> - <select class="form-control" id="translation-convert-hiragana-to-katakana"> + <select class="form-control" id="translation-convert-hiragana-to-katakana" data-setting="translation.convertHiraganaToKatakana"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -483,7 +495,7 @@ <div class="form-group"> <label for="translation-convert-katakana-to-hiragana">Convert katakana to hiragana <span class="label-light">(ヨミチャン → よみちゃん)</span></label> - <select class="form-control" id="translation-convert-katakana-to-hiragana"> + <select class="form-control" id="translation-convert-katakana-to-hiragana" data-setting="translation.convertKatakanaToHiragana"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -492,7 +504,7 @@ <div class="form-group"> <label for="translation-collapse-emphatic-sequences">Collapse emphatic character sequences <span class="label-light">(すっっごーーい → すっごーい / すごい)</span></label> - <select class="form-control" id="translation-collapse-emphatic-sequences"> + <select class="form-control" id="translation-collapse-emphatic-sequences" data-setting="translation.collapseEmphaticSequences"> <option value="false">Disabled</option> <option value="true">Collapse into single character</option> <option value="full">Remove all characters</option> @@ -509,24 +521,24 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="enable-search-within-first-popup"> Enable search when clicking glossary entries and tags</label> + <label><input type="checkbox" id="enable-search-within-first-popup" data-setting="scanning.enablePopupSearch"> Enable search when clicking glossary entries and tags</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="enable-scanning-on-search-page"> Enable scanning on search page</label> + <label><input type="checkbox" id="enable-scanning-on-search-page" data-setting="scanning.enableOnSearchPage"> Enable scanning on search page</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of expressions in search results</label> + <label><input type="checkbox" id="enable-scanning-of-popup-expressions" data-setting="scanning.enableOnPopupExpressions"> Enable scanning of expressions in search results</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="enable-search-tags"> Enable clickable and scannable tags for searching expressions and their readings</label> + <label><input type="checkbox" id="enable-search-tags" data-setting="scanning.enableSearchTags"> Enable clickable and scannable tags for searching expressions and their readings</label> </div> <div class="form-group"> <label for="popup-nesting-max-depth">Maximum number of additional popups</label> - <input type="number" min="0" step="1" id="popup-nesting-max-depth" class="form-control"> + <input type="number" min="0" step="1" id="popup-nesting-max-depth" class="form-control" data-setting="scanning.popupNestingMaxDepth"> </div> </div> @@ -551,20 +563,20 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="parsing-scan-enable"> Enable text parsing using installed dictionaries</label> + <label><input type="checkbox" id="parsing-scan-enable" data-setting="parsing.enableScanningParser"> Enable text parsing using installed dictionaries</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="parsing-mecab-enable"> Enable text parsing using MeCab</label> + <label><input type="checkbox" id="parsing-mecab-enable" data-setting="parsing.enableMecabParser"> Enable text parsing using MeCab</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="parsing-term-spacing"> Enable small spaces between parsed words</label> + <label><input type="checkbox" id="parsing-term-spacing" data-setting="parsing.termSpacing"> Enable small spaces between parsed words</label> </div> <div class="form-group"> <label for="parsing-reading-mode">Reading mode</label> - <select class="form-control" id="parsing-reading-mode"> + <select class="form-control" id="parsing-reading-mode" data-setting="parsing.readingMode"> <option value="hiragana">ひらがな</option> <option value="katakana">カタカナ</option> <option value="romaji">Romaji</option> @@ -621,7 +633,7 @@ </div> <div class="checkbox"> - <label><input type="checkbox" id="database-enable-prefix-wildcard-searches"> Enable prefix wildcard searches</label> + <label><input type="checkbox" id="database-enable-prefix-wildcard-searches" data-setting="global.database.prefixWildcardsSupported" data-scope="global"> Enable prefix wildcard searches</label> <p class="help-block"> This option only applies to newly imported dictionaries. Enabling this option will also cause dictionary data to take up slightly more storage space. @@ -711,7 +723,6 @@ <div id="storage-info"> <div> - <img src="/mixed/img/spinner.gif" class="pull-right" id="storage-spinner" /> <h3>Storage</h3> </div> @@ -771,7 +782,7 @@ <div> <div> - <img src="/mixed/img/spinner.gif" class="pull-right" id="anki-spinner" alt> + <img src="/mixed/img/spinner.gif" class="pull-right" id="anki-spinner" alt hidden> <h3>Anki Options</h3> </div> @@ -782,7 +793,7 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="anki-enable"> Enable Anki integration</label> + <label><input type="checkbox" id="anki-enable" data-setting="anki.enable" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-anki-enable"> Enable Anki integration</label> </div> <div id="anki-general"> @@ -805,22 +816,22 @@ <div class="form-group"> <label for="card-tags">Card tags <span class="label-light">(comma or space separated)</span></label> - <input type="text" id="card-tags" class="form-control"> + <input type="text" id="card-tags" class="form-control" data-setting="anki.tags" data-transform-pre="splitTags" data-transform-post="joinTags"> </div> <div class="form-group options-advanced"> <label for="sentence-detection-extent">Sentence detection extent <span class="label-light">(in characters)</span></label> - <input type="number" min="1" step="1" id="sentence-detection-extent" class="form-control"> + <input type="number" min="1" step="1" id="sentence-detection-extent" class="form-control" data-setting="anki.sentenceExt"> </div> <div class="form-group options-advanced"> <label for="interface-server">Interface server <span class="label-light">(Default: http://127.0.0.1:8765)</span></label> - <input type="text" id="interface-server" class="form-control"> + <input type="text" id="interface-server" class="form-control" data-setting="anki.server"> </div> <div class="form-group options-advanced"> <label for="duplicate-scope">Duplicate scope</label> - <select class="form-control" id="duplicate-scope"> + <select class="form-control" id="duplicate-scope" data-setting="anki.duplicateScope"> <option value="collection">Collection</option> <option value="deck">Deck</option> </select> @@ -828,7 +839,7 @@ <div class="form-group options-advanced"> <label for="screenshot-format">Screenshot format</label> - <select class="form-control" id="screenshot-format"> + <select class="form-control" id="screenshot-format" data-setting="anki.screenshot.format"> <option value="png">PNG</option> <option value="jpeg">JPEG</option> </select> @@ -836,7 +847,7 @@ <div class="form-group options-advanced"> <label for="screenshot-quality">Screenshot quality <span class="label-light">(JPEG only)</span></label> - <input type="number" min="0" max="100" step="1" id="screenshot-quality" class="form-control"> + <input type="number" min="0" max="100" step="1" id="screenshot-quality" class="form-control" data-setting="anki.screenshot.quality"> </div> <div id="anki-format"> @@ -854,7 +865,7 @@ <li><a href="#kanji" data-toggle="tab">Kanji</a></li> </ul> - <div class="tab-content"> + <div class="tab-content ignore-form-changes" id="anki-fields-container"> <div id="terms" class="tab-pane fade in active" data-anki-card-type="terms"> <div class="row"> <div class="form-group col-xs-6"> @@ -1111,8 +1122,6 @@ </p> </div> - <pre id="debug" class="debug"></pre> - <div class="pull-right bottom-links"> <small><span id="extension-info"></span> • <a href="search.html">Search</a> • <a href="https://foosoft.net/projects/yomichan/" target="_blank" rel="noopener">Homepage</a> • <a href="legal.html">Legal</a></small> </div> @@ -1124,6 +1133,7 @@ <script src="/mixed/lib/wanakana.min.js"></script> <script src="/mixed/js/core.js"></script> + <script src="/mixed/js/comm.js"></script> <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/environment.js"></script> <script src="/mixed/js/api.js"></script> @@ -1143,13 +1153,18 @@ <script src="/bg/js/settings/anki.js"></script> <script src="/bg/js/settings/anki-templates.js"></script> <script src="/bg/js/settings/audio.js"></script> - <script src="/bg/js/settings/audio-ui.js"></script> <script src="/bg/js/settings/backup.js"></script> + <script src="/bg/js/settings/clipboard-popups-controller.js"></script> <script src="/bg/js/settings/conditions-ui.js"></script> <script src="/bg/js/settings/dictionaries.js"></script> + <script src="/bg/js/settings/generic-setting-controller.js"></script> <script src="/bg/js/settings/popup-preview.js"></script> <script src="/bg/js/settings/profiles.js"></script> + <script src="/bg/js/settings/settings-controller.js"></script> <script src="/bg/js/settings/storage.js"></script> + <script src="/mixed/js/object-property-accessor.js"></script> + <script src="/mixed/js/task-accumulator.js"></script> + <script src="/mixed/js/dom-data-binder.js"></script> <script src="/bg/js/settings/main.js"></script> </body> diff --git a/ext/fg/float.html b/ext/fg/float.html index 89952524..3e41cde5 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -17,8 +17,8 @@ <div id="spinner" hidden><img src="/mixed/img/spinner.gif"></div> <div id="navigation-header" class="navigation-header" hidden><div class="navigation-header-actions"> - <button class="action-button action-previous"><img src="/mixed/img/source-term.svg" class="icon-image" title="Source term (Alt + B)" alt></button> - <button class="action-button action-next"><img src="/mixed/img/source-term.svg" class="icon-image" title="Next term (Alt + F)" alt></button> + <button class="action-button action-previous" data-icon="source-term" title="Source term (Alt + B)"></button> + <button class="action-button action-next" data-icon="source-term" title="Next term (Alt + F)"></button> </div></div><div class="navigation-header-spacer"></div> <div id="definitions"></div> @@ -40,11 +40,13 @@ </div> <script src="/mixed/js/core.js"></script> + <script src="/mixed/js/comm.js"></script> <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/api.js"></script> <script src="/mixed/js/japanese.js"></script> <script src="/fg/js/document.js"></script> + <script src="/fg/js/dom-text-scanner.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> diff --git a/ext/fg/js/content-script-main.js b/ext/fg/js/content-script-main.js index 57386b85..1f3a69e5 100644 --- a/ext/fg/js/content-script-main.js +++ b/ext/fg/js/content-script-main.js @@ -16,141 +16,31 @@ */ /* global - * DOM - * FrameOffsetForwarder * Frontend * PopupFactory - * PopupProxy - * apiBroadcastTab - * apiForwardLogsToBackend - * apiFrameInformationGet - * apiOptionsGet + * api */ -async function createIframePopupProxy(frameOffsetForwarder, setDisabled) { - const rootPopupInformationPromise = yomichan.getTemporaryListenerResult( - chrome.runtime.onMessage, - ({action, params}, {resolve}) => { - if (action === 'rootPopupInformation') { - resolve(params); - } - } - ); - apiBroadcastTab('rootPopupRequestInformationBroadcast'); - const {popupId, frameId: parentFrameId} = await rootPopupInformationPromise; - - const getFrameOffset = frameOffsetForwarder.getOffset.bind(frameOffsetForwarder); - - const popup = new PopupProxy(popupId, 0, null, parentFrameId, getFrameOffset, setDisabled); - await popup.prepare(); - - return popup; -} - -async function getOrCreatePopup(depth) { - const {frameId} = await apiFrameInformationGet(); - if (typeof frameId !== 'number') { - const error = new Error('Failed to get frameId'); - yomichan.logError(error); - throw error; - } - - const popupFactory = new PopupFactory(frameId); - await popupFactory.prepare(); - - const popup = popupFactory.getOrCreatePopup(null, null, depth); - - return popup; -} - -async function createPopupProxy(depth, id, parentFrameId) { - const popup = new PopupProxy(null, depth + 1, id, parentFrameId); - await popup.prepare(); - - return popup; -} - (async () => { - apiForwardLogsToBackend(); - await yomichan.prepare(); - - const data = window.frontendInitializationData || {}; - const {id, depth=0, parentFrameId, url=window.location.href, proxy=false, isSearchPage=false} = data; - - const isIframe = !proxy && (window !== window.parent); + try { + api.forwardLogsToBackend(); + await yomichan.prepare(); - const popups = { - iframe: null, - proxy: null, - normal: null - }; - - let frontend = null; - 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: proxy ? await getPopupProxyUrl() : window.location.href - }; - const options = await apiOptionsGet(optionsContext); - - if (!proxy && frameOffsetForwarder === null) { - frameOffsetForwarder = new FrameOffsetForwarder(); - frameOffsetForwarder.start(); + const {frameId} = await api.frameInformationGet(); + if (typeof frameId !== 'number') { + throw new Error('Failed to get frameId'); } - let popup; - 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); - popups.proxy = popup; - } else { - popup = popups.normal || await getOrCreatePopup(depth); - popups.normal = popup; - } - - if (frontend === null) { - const getUrl = proxy ? getPopupProxyUrl : null; - frontend = new Frontend(popup, getUrl); - frontendPreparePromise = frontend.prepare(); - await frontendPreparePromise; - } else { - await frontendPreparePromise; - if (isSearchPage) { - const disabled = !options.scanning.enableOnSearchPage; - frontend.setDisabledOverride(disabled); - } - - if (isIframe) { - await frontend.setPopup(popup); - } - } - }; - - yomichan.on('optionsUpdated', applyOptions); - window.addEventListener('fullscreenchange', applyOptions, false); - - await applyOptions(); + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const frontend = new Frontend( + frameId, + popupFactory, + {} + ); + await frontend.prepare(); + } catch (e) { + yomichan.logError(e); + } })(); diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index d639bc86..c288502c 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -17,6 +17,7 @@ /* global * DOM + * DOMTextScanner * TextSourceElement * TextSourceRange */ @@ -152,14 +153,14 @@ function docRangeFromPoint(x, y, deepDomScan) { } } -function docSentenceExtract(source, extent) { +function docSentenceExtract(source, extent, layoutAwareScan) { const quotesFwd = {'「': '」', '『': '』', "'": "'", '"': '"'}; const quotesBwd = {'」': '「', '』': '『', "'": "'", '"': '"'}; const terminators = '…。..??!!'; const sourceLocal = source.clone(); - const position = sourceLocal.setStartOffset(extent); - sourceLocal.setEndOffset(extent * 2 - position, true); + const position = sourceLocal.setStartOffset(extent, layoutAwareScan); + sourceLocal.setEndOffset(extent * 2 - position, layoutAwareScan, true); const content = sourceLocal.text(); let quoteStack = []; @@ -232,7 +233,7 @@ function isPointInRange(x, y, range) { const nodePre = range.endContainer; const offsetPre = range.endOffset; try { - const {node, offset, content} = TextSourceRange.seekForward(range.endContainer, range.endOffset, 1); + const {node, offset, content} = new DOMTextScanner(range.endContainer, range.endOffset, true, false).seek(1); range.setEnd(node, offset); if (!isWhitespace(content) && DOM.isPointInAnyRect(x, y, range.getClientRects())) { @@ -243,7 +244,7 @@ function isPointInRange(x, y, range) { } // Scan backward - const {node, offset, content} = TextSourceRange.seekBackward(range.startContainer, range.startOffset, 1); + const {node, offset, content} = new DOMTextScanner(range.startContainer, range.startOffset, true, false).seek(-1); range.setStart(node, offset); if (!isWhitespace(content) && DOM.isPointInAnyRect(x, y, range.getClientRects())) { diff --git a/ext/fg/js/float-main.js b/ext/fg/js/float-main.js index 20771910..3bedfe58 100644 --- a/ext/fg/js/float-main.js +++ b/ext/fg/js/float-main.js @@ -17,45 +17,16 @@ /* global * DisplayFloat - * apiForwardLogsToBackend - * apiOptionsGet - * dynamicLoader + * api */ -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/content-script-main.js' - ]); -} - -async function popupNestedInitialize(id, depth, parentFrameId, url) { - let optionsApplied = false; - - const applyOptions = async () => { - const optionsContext = {depth, url}; - const options = await apiOptionsGet(optionsContext); - 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}; - await injectPopupNested(); - }; - - yomichan.on('optionsUpdated', applyOptions); - - await applyOptions(); -} - (async () => { - apiForwardLogsToBackend(); - const display = new DisplayFloat(); - await display.prepare(); + try { + api.forwardLogsToBackend(); + + const display = new DisplayFloat(); + await display.prepare(); + } catch (e) { + yomichan.logError(e); + } })(); diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 845bf7f6..d7beb675 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -17,9 +17,10 @@ /* global * Display - * apiBroadcastTab - * apiSendMessageToFrame - * popupNestedInitialize + * Frontend + * PopupFactory + * api + * dynamicLoader */ class DisplayFloat extends Display { @@ -31,7 +32,7 @@ class DisplayFloat extends Display { this._token = null; this._orphaned = false; - this._initializedNestedPopups = false; + this._nestedPopupsPrepared = false; this._onKeyDownHandlers = new Map([ ['C', (e) => { @@ -61,7 +62,7 @@ class DisplayFloat extends Display { yomichan.on('orphaned', this.onOrphaned.bind(this)); window.addEventListener('message', this.onMessage.bind(this), false); - apiBroadcastTab('popupPrepared', {secret: this._secret}); + api.broadcastTab('popupPrepared', {secret: this._secret}); } onError(error) { @@ -153,7 +154,7 @@ class DisplayFloat extends Display { }, 2000 ); - apiBroadcastTab('requestDocumentInformationBroadcast', {uniqueId}); + api.broadcastTab('requestDocumentInformationBroadcast', {uniqueId}); const {title} = await promise; return title; @@ -176,7 +177,7 @@ class DisplayFloat extends Display { const {token, frameId} = params; this._token = token; - apiSendMessageToFrame(frameId, 'popupInitialized', {secret, token}); + api.sendMessageToFrame(frameId, 'popupInitialized', {secret, token}); } async _configure({messageId, frameId, popupId, optionsContext, childrenSupported, scale}) { @@ -184,15 +185,15 @@ class DisplayFloat extends Display { await this.updateOptions(); - if (childrenSupported && !this._initializedNestedPopups) { + if (childrenSupported && !this._nestedPopupsPrepared) { const {depth, url} = optionsContext; - popupNestedInitialize(popupId, depth, frameId, url); - this._initializedNestedPopups = true; + this._prepareNestedPopups(popupId, depth, frameId, url); + this._nestedPopupsPrepared = true; } this.setContentScale(scale); - apiSendMessageToFrame(frameId, 'popupConfigured', {messageId}); + api.sendMessageToFrame(frameId, 'popupConfigured', {messageId}); } _isMessageAuthenticated(message) { @@ -202,4 +203,57 @@ class DisplayFloat extends Display { this._secret === message.secret ); } + + async _prepareNestedPopups(id, depth, parentFrameId, url) { + let complete = false; + + const onOptionsUpdated = async () => { + const optionsContext = this.optionsContext; + const options = await api.optionsGet(optionsContext); + const maxPopupDepthExceeded = !(typeof depth === 'number' && depth < options.scanning.popupNestingMaxDepth); + if (maxPopupDepthExceeded || complete) { return; } + + complete = true; + yomichan.off('optionsUpdated', onOptionsUpdated); + + try { + await this._setupNestedPopups(id, depth, parentFrameId, url); + } catch (e) { + yomichan.logError(e); + } + }; + + yomichan.on('optionsUpdated', onOptionsUpdated); + + await onOptionsUpdated(); + } + + async _setupNestedPopups(id, depth, parentFrameId, url) { + await dynamicLoader.loadScripts([ + '/mixed/js/text-scanner.js', + '/fg/js/popup.js', + '/fg/js/popup-proxy.js', + '/fg/js/popup-factory.js', + '/fg/js/frame-offset-forwarder.js', + '/fg/js/frontend.js' + ]); + + const {frameId} = await api.frameInformationGet(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const frontend = new Frontend( + frameId, + popupFactory, + { + id, + depth, + parentFrameId, + url, + proxy: true + } + ); + await frontend.prepare(); + } } diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js index 9b68d34e..f692364a 100644 --- a/ext/fg/js/frame-offset-forwarder.js +++ b/ext/fg/js/frame-offset-forwarder.js @@ -16,13 +16,12 @@ */ /* global - * apiBroadcastTab + * api */ class FrameOffsetForwarder { constructor() { - this._started = false; - + this._isPrepared = false; this._cacheMaxSize = 1000; this._frameCache = new Set(); this._unreachableContentWindowCache = new Set(); @@ -38,10 +37,10 @@ class FrameOffsetForwarder { ]); } - start() { - if (this._started) { return; } - window.addEventListener('message', this.onMessage.bind(this), false); - this._started = true; + prepare() { + if (this._isPrepared) { return; } + window.addEventListener('message', this._onMessage.bind(this), false); + this._isPrepared = true; } async getOffset() { @@ -69,11 +68,20 @@ class FrameOffsetForwarder { return offset; } - onMessage(e) { - const {action, params} = e.data; - const handler = this._windowMessageHandlers.get(action); - if (typeof handler !== 'function') { return; } - handler(params, e); + // Private + + _onMessage(event) { + const data = event.data; + if (data === null || typeof data !== 'object') { return; } + + try { + const {action, params} = event.data; + const handler = this._windowMessageHandlers.get(action); + if (typeof handler !== 'function') { return; } + handler(params, event); + } catch (e) { + // NOP + } } _onGetFrameOffset(offset, uniqueId, e) { @@ -161,6 +169,6 @@ class FrameOffsetForwarder { } _forwardFrameOffsetOrigin(offset, uniqueId) { - apiBroadcastTab('frameOffset', {offset, uniqueId}); + api.broadcastTab('frameOffset', {offset, uniqueId}); } } diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js deleted file mode 100644 index 3fa9e8b6..00000000 --- a/ext/fg/js/frontend-api-receiver.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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/>. - */ - - -class FrontendApiReceiver { - constructor(source, messageHandlers) { - this._source = source; - this._messageHandlers = messageHandlers; - } - - prepare() { - chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); - } - - _onConnect(port) { - if (port.name !== 'frontend-api-receiver') { return; } - - port.onMessage.addListener(this._onMessage.bind(this, port)); - } - - _onMessage(port, {id, action, params, target, senderId}) { - if (target !== this._source) { return; } - - const messageHandler = this._messageHandlers.get(action); - if (typeof messageHandler === 'undefined') { return; } - - const {handler, async} = messageHandler; - - 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)}); - } - } - - 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) { - port.postMessage({type: 'ack', id, senderId}); - } - - _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 deleted file mode 100644 index 4dcde638..00000000 --- a/ext/fg/js/frontend-api-sender.js +++ /dev/null @@ -1,128 +0,0 @@ -/* - * 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/>. - */ - - -class FrontendApiSender { - 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) { - if (this._disconnected) { - // attempt to reconnect the next time - this._disconnected = false; - return Promise.reject(new Error('Disconnected')); - } - - if (this._port === null) { - this._createPort(); - } - - 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._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)); - } - - _onMessage({type, id, data, senderId}) { - if (senderId !== this._senderId) { return; } - switch (type) { - case 'ack': - this._onAck(id); - break; - case 'result': - this._onResult(id, data); - break; - } - } - - _onDisconnect() { - this._disconnected = true; - this._port = null; - - for (const id of this._callbacks.keys()) { - this._onError(id, 'Disconnected'); - } - } - - _onAck(id) { - const info = this._callbacks.get(id); - if (typeof info === 'undefined') { - yomichan.logWarning(new Error(`ID ${id} not found for ack`)); - return; - } - - if (info.ack) { - 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); - } - - _onResult(id, data) { - const info = this._callbacks.get(id); - if (typeof info === 'undefined') { - yomichan.logWarning(new Error(`ID ${id} not found`)); - return; - } - - if (!info.ack) { - yomichan.logWarning(new Error(`Request ${id} not ack'd`)); - return; - } - - this._callbacks.delete(id); - clearTimeout(info.timer); - info.timer = null; - - if (typeof data.error !== 'undefined') { - info.reject(jsonToError(data.error)); - } else { - info.resolve(data.result); - } - } - - _onError(id, reason) { - const info = this._callbacks.get(id); - if (typeof info === 'undefined') { return; } - 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 575dc413..f6b0d236 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -16,20 +16,18 @@ */ /* global + * DOM + * FrameOffsetForwarder + * PopupProxy * TextScanner - * apiBroadcastTab - * apiGetZoom - * apiKanjiFind - * apiOptionsGet - * apiTermsFind + * api * docSentenceExtract */ class Frontend { - constructor(popup, getUrl=null) { + constructor(frameId, popupFactory, frontendInitializationData) { this._id = yomichan.generateId(16); - this._popup = popup; - this._getUrl = getUrl; + this._popup = null; this._disabledOverride = false; this._options = null; this._pageZoomFactor = 1.0; @@ -41,11 +39,31 @@ class Frontend { this._optionsUpdatePending = false; this._textScanner = new TextScanner({ node: window, - ignoreElements: () => this._popup.isProxy() ? [] : [this._popup.getFrame()], - ignorePoint: (x, y) => this._popup.containsPoint(x, y), + ignoreElements: this._ignoreElements.bind(this), + ignorePoint: this._ignorePoint.bind(this), search: this._search.bind(this) }); + const { + depth=0, + id: proxyPopupId, + parentFrameId, + proxy: useProxyPopup=false, + isSearchPage=false, + allowRootFramePopupProxy=true + } = frontendInitializationData; + this._proxyPopupId = proxyPopupId; + this._parentFrameId = parentFrameId; + this._useProxyPopup = useProxyPopup; + this._isSearchPage = isSearchPage; + this._depth = depth; + this._frameId = frameId; + this._frameOffsetForwarder = new FrameOffsetForwarder(); + this._popupFactory = popupFactory; + this._allowRootFramePopupProxy = allowRootFramePopupProxy; + this._popupCache = new Map(); + this._updatePopupToken = null; + this._windowMessageHandlers = new Map([ ['popupClose', this._onMessagePopupClose.bind(this)], ['selectionCopy', this._onMessageSelectionCopy.bind()] @@ -66,39 +84,46 @@ class Frontend { this._textScanner.canClearSelection = value; } + get popup() { + return this._popup; + } + async prepare() { + this._frameOffsetForwarder.prepare(); + + await this.updateOptions(); try { - await this.updateOptions(); - const {zoomFactor} = await apiGetZoom(); + const {zoomFactor} = await api.getZoom(); this._pageZoomFactor = zoomFactor; + } catch (e) { + // Ignore exceptions which may occur due to being on an unsupported page (e.g. about:blank) + } - window.addEventListener('resize', this._onResize.bind(this), false); + this._textScanner.prepare(); - 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.addEventListener('resize', this._onResize.bind(this), false); + DOM.addFullscreenChangeEventListener(this._updatePopup.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)); + const visualViewport = window.visualViewport; + if (visualViewport !== null && typeof visualViewport === 'object') { + visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this)); + visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this)); + } - this._textScanner.on('clearSelection', this._onClearSelection.bind(this)); - this._textScanner.on('activeModifiersChanged', this._onActiveModifiersChanged.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)); - this._updateContentScale(); - this._broadcastRootPopupInformation(); - } catch (e) { - yomichan.logError(e); - } - } + this._textScanner.on('clearSelection', this._onClearSelection.bind(this)); + this._textScanner.on('activeModifiersChanged', this._onActiveModifiersChanged.bind(this)); - async setPopup(popup) { - this._textScanner.clearSelection(true); - this._popup = popup; - await popup.setOptionsContext(await this.getOptionsContext(), this._id); + api.crossFrame.registerHandlers([ + ['getUrl', {async: false, handler: this._onApiGetUrl.bind(this)}] + ]); + + this._updateContentScale(); + this._broadcastRootPopupInformation(); } setDisabledOverride(disabled) { @@ -112,15 +137,26 @@ class Frontend { } async getOptionsContext() { - const url = this._getUrl !== null ? await this._getUrl() : window.location.href; - const depth = this._popup.depth; + let url = window.location.href; + if (this._useProxyPopup) { + try { + url = await api.crossFrame.invoke(this._parentFrameId, 'getUrl', {}); + } catch (e) { + // NOP + } + } + + const depth = this._depth; const modifierKeys = [...this._activeModifiers]; return {depth, url, modifierKeys}; } async updateOptions() { const optionsContext = await this.getOptionsContext(); - this._options = await apiOptionsGet(optionsContext); + this._options = await api.optionsGet(optionsContext); + + await this._updatePopup(); + this._textScanner.setOptions(this._options); this._updateTextScannerEnabled(); @@ -130,8 +166,6 @@ class Frontend { } this._textScanner.ignoreNodes = ignoreNodes.join(','); - await this._popup.setOptionsContext(optionsContext, this._id); - this._updateContentScale(); const textSourceCurrent = this._textScanner.getCurrentTextSource(); @@ -167,6 +201,12 @@ class Frontend { this._broadcastDocumentInformation(uniqueId); } + // API message handlers + + _onApiGetUrl() { + return window.location.href; + } + // Private _onResize() { @@ -223,6 +263,95 @@ class Frontend { await this.updateOptions(); } + async _updatePopup() { + const showIframePopupsInRootFrame = this._options.general.showIframePopupsInRootFrame; + const isIframe = !this._useProxyPopup && (window !== window.parent); + + let popupPromise; + if ( + isIframe && + showIframePopupsInRootFrame && + DOM.getFullscreenElement() === null && + this._allowRootFramePopupProxy + ) { + popupPromise = this._popupCache.get('iframe'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getIframeProxyPopup(); + this._popupCache.set('iframe', popupPromise); + } + } else if (this._useProxyPopup) { + popupPromise = this._popupCache.get('proxy'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getProxyPopup(); + this._popupCache.set('proxy', popupPromise); + } + } else { + popupPromise = this._popupCache.get('default'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getDefaultPopup(); + this._popupCache.set('default', popupPromise); + } + } + + // The token below is used as a unique identifier to ensure that a new _updatePopup call + // hasn't been started during the await. + const token = {}; + this._updatePopupToken = token; + const popup = await popupPromise; + const optionsContext = await this.getOptionsContext(); + if (this._updatePopupToken !== token) { return; } + await popup.setOptionsContext(optionsContext, this._id); + if (this._updatePopupToken !== token) { return; } + + if (this._isSearchPage) { + this.setDisabledOverride(!this._options.scanning.enableOnSearchPage); + } + + this._textScanner.clearSelection(true); + this._popup = popup; + this._depth = popup.depth; + } + + async _getDefaultPopup() { + return this._popupFactory.getOrCreatePopup(null, null, this._depth); + } + + async _getProxyPopup() { + const popup = new PopupProxy(null, this._depth + 1, this._proxyPopupId, this._parentFrameId); + await popup.prepare(); + return popup; + } + + async _getIframeProxyPopup() { + const rootPopupInformationPromise = yomichan.getTemporaryListenerResult( + chrome.runtime.onMessage, + ({action, params}, {resolve}) => { + if (action === 'rootPopupInformation') { + resolve(params); + } + } + ); + api.broadcastTab('rootPopupRequestInformationBroadcast'); + const {popupId, frameId: parentFrameId} = await rootPopupInformationPromise; + + const popup = new PopupProxy(popupId, 0, null, parentFrameId, this._frameOffsetForwarder); + popup.on('offsetNotFound', () => { + this._allowRootFramePopupProxy = false; + this._updatePopup(); + }); + await popup.prepare(); + + return popup; + } + + _ignoreElements() { + return this._popup === null || this._popup.isProxy() ? [] : [this._popup.getContainer()]; + } + + _ignorePoint(x, y) { + return this._popup !== null && this._popup.containsPoint(x, y); + } + async _search(textSource, cause) { await this._updatePendingOptions(); @@ -258,32 +387,36 @@ class Frontend { } async _findTerms(textSource, optionsContext) { - const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length); + const {length: scanLength, layoutAwareScan} = this._options.scanning; + const searchText = this._textScanner.getTextSourceContent(textSource, scanLength, layoutAwareScan); if (searchText.length === 0) { return null; } - const {definitions, length} = await apiTermsFind(searchText, {}, optionsContext); + const {definitions, length} = await api.termsFind(searchText, {}, optionsContext); if (definitions.length === 0) { return null; } - textSource.setEndOffset(length); + textSource.setEndOffset(length, layoutAwareScan); return {definitions, type: 'terms'}; } async _findKanji(textSource, optionsContext) { - const searchText = this._textScanner.getTextSourceContent(textSource, 1); + const layoutAwareScan = this._options.scanning.layoutAwareScan; + const searchText = this._textScanner.getTextSourceContent(textSource, 1, layoutAwareScan); if (searchText.length === 0) { return null; } - const definitions = await apiKanjiFind(searchText, optionsContext); + const definitions = await api.kanjiFind(searchText, optionsContext); if (definitions.length === 0) { return null; } - textSource.setEndOffset(1); + textSource.setEndOffset(1, layoutAwareScan); return {definitions, type: 'kanji'}; } _showContent(textSource, focus, definitions, type, optionsContext) { const {url} = optionsContext; - const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt); + const sentenceExtent = this._options.anki.sentenceExt; + const layoutAwareScan = this._options.scanning.layoutAwareScan; + const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); this._showPopupContent( textSource, optionsContext, @@ -314,7 +447,7 @@ class Frontend { _updateTextScannerEnabled() { const enabled = ( this._options.general.enable && - this._popup.depth <= this._options.scanning.popupNestingMaxDepth && + this._depth <= this._options.scanning.popupNestingMaxDepth && !this._disabledOverride ); this._enabledEventListeners.removeAllEventListeners(); @@ -338,27 +471,41 @@ class Frontend { if (contentScale === this._contentScale) { return; } this._contentScale = contentScale; - this._popup.setContentScale(this._contentScale); + if (this._popup !== null) { + this._popup.setContentScale(this._contentScale); + } this._updatePopupPosition(); } async _updatePopupPosition() { const textSource = this._textScanner.getCurrentTextSource(); - if (textSource !== null && await this._popup.isVisible()) { + if ( + textSource !== null && + this._popup !== 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 !== null && + !this._popup.isProxy() && + this._depth === 0 && + this._frameId === 0 + ) { + api.broadcastTab('rootPopupInformation', { + popupId: this._popup.id, + frameId: this._frameId + }); } } _broadcastDocumentInformation(uniqueId) { - apiBroadcastTab('documentInformationBroadcast', { + api.broadcastTab('documentInformationBroadcast', { uniqueId, - frameId: this._popup.frameId, + frameId: this._frameId, title: document.title }); } diff --git a/ext/fg/js/popup-factory.js b/ext/fg/js/popup-factory.js index b10acbaf..904f18b9 100644 --- a/ext/fg/js/popup-factory.js +++ b/ext/fg/js/popup-factory.js @@ -16,8 +16,8 @@ */ /* global - * FrontendApiReceiver * Popup + * api */ class PopupFactory { @@ -28,8 +28,8 @@ class PopupFactory { // Public functions - async prepare() { - const apiReceiver = new FrontendApiReceiver(`popup-factory#${this._frameId}`, new Map([ + prepare() { + api.crossFrame.registerHandlers([ ['getOrCreatePopup', {async: false, handler: this._onApiGetOrCreatePopup.bind(this)}], ['setOptionsContext', {async: true, handler: this._onApiSetOptionsContext.bind(this)}], ['hide', {async: false, handler: this._onApiHide.bind(this)}], @@ -39,10 +39,8 @@ class PopupFactory { ['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(); + ['setContentScale', {async: false, handler: this._onApiSetContentScale.bind(this)}] + ]); } getOrCreatePopup(id=null, parentId=null, depth=null) { @@ -148,10 +146,6 @@ class PopupFactory { return popup.setContentScale(scale); } - _onApiGetUrl() { - return window.location.href; - } - // Private functions _getPopup(id) { diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index 82da839a..a6602eae 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -16,17 +16,17 @@ */ /* global - * FrontendApiSender + * api */ -class PopupProxy { - constructor(id, depth, parentPopupId, parentFrameId, getFrameOffset=null, setDisabled=null) { +class PopupProxy extends EventDispatcher { + constructor(id, depth, parentPopupId, parentFrameId, frameOffsetForwarder=null) { + super(); this._id = id; this._depth = depth; this._parentPopupId = parentPopupId; - this._apiSender = new FrontendApiSender(`popup-factory#${parentFrameId}`); - this._getFrameOffset = getFrameOffset; - this._setDisabled = setDisabled; + this._parentFrameId = parentFrameId; + this._frameOffsetForwarder = frameOffsetForwarder; this._frameOffset = null; this._frameOffsetPromise = null; @@ -75,7 +75,7 @@ class PopupProxy { } async containsPoint(x, y) { - if (this._getFrameOffset !== null) { + if (this._frameOffsetForwarder !== null) { await this._updateFrameOffset(); [x, y] = this._applyFrameOffset(x, y); } @@ -84,7 +84,7 @@ class PopupProxy { async showContent(elementRect, writingMode, type, details, context) { let {x, y, width, height} = elementRect; - if (this._getFrameOffset !== null) { + if (this._frameOffsetForwarder !== null) { await this._updateFrameOffset(); [x, y] = this._applyFrameOffset(x, y); } @@ -104,14 +104,10 @@ class PopupProxy { this._invoke('setContentScale', {id: this._id, scale}); } - async getUrl() { - return await this._invoke('getUrl', {}); - } - // Private _invoke(action, params={}) { - return this._apiSender.invoke(action, params); + return api.crossFrame.invoke(this._parentFrameId, action, params); } async _updateFrameOffset() { @@ -134,12 +130,12 @@ class PopupProxy { } async _updateFrameOffsetInner(now) { - this._frameOffsetPromise = this._getFrameOffset(); + this._frameOffsetPromise = this._frameOffsetForwarder.getOffset(); try { const offset = await this._frameOffsetPromise; this._frameOffset = offset !== null ? offset : [0, 0]; - if (offset === null && this._setDisabled !== null) { - this._setDisabled(); + if (offset === null) { + this.trigger('offsetNotFound'); return; } this._frameOffsetUpdatedAt = now; diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index b7d4b57e..5ee62c9b 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -17,7 +17,7 @@ /* global * DOM - * apiOptionsGet + * api * dynamicLoader */ @@ -47,6 +47,9 @@ class Popup { this._frame.style.width = '0'; this._frame.style.height = '0'; + this._container = this._frame; + this._shadow = null; + this._fullscreenEventListeners = new EventListenerCollection(); } @@ -89,7 +92,7 @@ class Popup { this._optionsContext = optionsContext; this._previousOptionsContextSource = source; - this._options = await apiOptionsGet(optionsContext); + this._options = await api.optionsGet(optionsContext); this.updateTheme(); this._invokeApi('setOptionsContext', {optionsContext}); @@ -180,7 +183,12 @@ class Popup { } async setCustomOuterCss(css, useWebExtensionApi) { - return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi); + let parentNode = null; + if (this._shadow !== null) { + useWebExtensionApi = false; + parentNode = this._shadow; + } + return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode); } setChildrenSupported(value) { @@ -195,6 +203,10 @@ class Popup { return this._frame.getBoundingClientRect(); } + getContainer() { + return this._container; + } + // Private functions _inject() { @@ -326,14 +338,25 @@ class Popup { } async _createInjectPromise() { - this._injectStyles(); + if (this._options === null) { + throw new Error('Options not initialized'); + } + + const {useSecurePopupFrameUrl, usePopupShadowDom} = this._options.general; + + await this._setUpContainer(usePopupShadowDom); 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'); + const url = chrome.runtime.getURL('/fg/float.html'); + if (useSecurePopupFrameUrl) { + frame.contentDocument.location.href = url; + } else { + frame.setAttribute('src', url); + } }); this._frameSecret = secret; this._frameToken = token; @@ -371,9 +394,9 @@ class Popup { } _resetFrame() { - const parent = this._frame.parentNode; + const parent = this._container.parentNode; if (parent !== null) { - parent.removeChild(this._frame); + parent.removeChild(this._container); } this._frame.removeAttribute('src'); this._frame.removeAttribute('srcdoc'); @@ -384,9 +407,31 @@ class Popup { this._injectPromiseComplete = false; } + async _setUpContainer(usePopupShadowDom) { + if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') { + const container = document.createElement('div'); + container.style.setProperty('all', 'initial', 'important'); + const shadow = container.attachShadow({mode: 'closed', delegatesFocus: true}); + shadow.appendChild(this._frame); + + this._container = container; + this._shadow = shadow; + } else { + const frameParentNode = this._frame.parentNode; + if (frameParentNode !== null) { + frameParentNode.removeChild(this._frame); + } + + this._container = this._frame; + this._shadow = null; + } + + await this._injectStyles(); + } + async _injectStyles() { try { - await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); + await this._injectPopupOuterStylesheet(); } catch (e) { // NOP } @@ -398,6 +443,18 @@ class Popup { } } + async _injectPopupOuterStylesheet() { + let fileType = 'file'; + let useWebExtensionApi = true; + let parentNode = null; + if (this._shadow !== null) { + fileType = 'file-content'; + useWebExtensionApi = false; + parentNode = this._shadow; + } + await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', fileType, '/fg/css/client.css', useWebExtensionApi, parentNode); + } + _observeFullscreen(observe) { if (!observe) { this._fullscreenEventListeners.removeAllEventListeners(); @@ -409,22 +466,13 @@ class Popup { return; } - const fullscreenEvents = [ - 'fullscreenchange', - 'MSFullscreenChange', - 'mozfullscreenchange', - 'webkitfullscreenchange' - ]; - const onFullscreenChanged = this._onFullscreenChanged.bind(this); - for (const eventName of fullscreenEvents) { - this._fullscreenEventListeners.addEventListener(document, eventName, onFullscreenChanged, false); - } + DOM.addFullscreenChangeEventListener(this._onFullscreenChanged.bind(this), this._fullscreenEventListeners); } _onFullscreenChanged() { const parent = this._getFrameParentElement(); - if (parent !== null && this._frame.parentNode !== parent) { - parent.appendChild(this._frame); + if (parent !== null && this._container.parentNode !== parent) { + parent.appendChild(this._container); } } diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index fa4706f2..38810f07 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -15,9 +15,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -// \u200c (Zero-width non-joiner) appears on Google Docs from Chrome 76 onwards -const IGNORE_TEXT_PATTERN = /\u200c/; - +/* global + * DOMTextScanner + */ /* * TextSourceRange @@ -46,19 +46,19 @@ class TextSourceRange { return this.content; } - setEndOffset(length, fromEnd=false) { + setEndOffset(length, layoutAwareScan, fromEnd=false) { const state = ( fromEnd ? - TextSourceRange.seekForward(this.range.endContainer, this.range.endOffset, length) : - TextSourceRange.seekForward(this.range.startContainer, this.range.startOffset, length) + new DOMTextScanner(this.range.endContainer, this.range.endOffset, !layoutAwareScan, layoutAwareScan).seek(length) : + new DOMTextScanner(this.range.startContainer, this.range.startOffset, !layoutAwareScan, layoutAwareScan).seek(length) ); this.range.setEnd(state.node, state.offset); this.content = (fromEnd ? this.content + state.content : state.content); return length - state.remainder; } - setStartOffset(length) { - const state = TextSourceRange.seekBackward(this.range.startContainer, this.range.startOffset, length); + setStartOffset(length, layoutAwareScan) { + const state = new DOMTextScanner(this.range.startContainer, this.range.startOffset, !layoutAwareScan, layoutAwareScan).seek(-length); this.range.setStart(state.node, state.offset); this.rangeStartOffset = this.range.startOffset; this.content = state.content + this.content; @@ -110,154 +110,6 @@ class TextSourceRange { } } - static shouldEnter(node) { - switch (node.nodeName.toUpperCase()) { - case 'RT': - case 'SCRIPT': - case 'STYLE': - return false; - } - - const style = window.getComputedStyle(node); - return !( - style.visibility === 'hidden' || - style.display === 'none' || - parseFloat(style.fontSize) === 0 - ); - } - - static getRubyElement(node) { - node = TextSourceRange.getParentElement(node); - if (node !== null && node.nodeName.toUpperCase() === 'RT') { - node = node.parentNode; - return (node !== null && node.nodeName.toUpperCase() === 'RUBY') ? node : null; - } - return null; - } - - static seekForward(node, offset, length) { - const state = {node, offset, remainder: length, content: ''}; - if (length <= 0) { - return state; - } - - const TEXT_NODE = Node.TEXT_NODE; - const ELEMENT_NODE = Node.ELEMENT_NODE; - let resetOffset = false; - - const ruby = TextSourceRange.getRubyElement(node); - if (ruby !== null) { - node = ruby; - resetOffset = true; - } - - while (node !== null) { - let visitChildren = true; - const nodeType = node.nodeType; - - if (nodeType === TEXT_NODE) { - state.node = node; - if (TextSourceRange.seekForwardTextNode(state, resetOffset)) { - break; - } - resetOffset = true; - } else if (nodeType === ELEMENT_NODE) { - visitChildren = TextSourceRange.shouldEnter(node); - } - - node = TextSourceRange.getNextNode(node, visitChildren); - } - - return state; - } - - static seekForwardTextNode(state, resetOffset) { - const nodeValue = state.node.nodeValue; - const nodeValueLength = nodeValue.length; - let content = state.content; - let offset = resetOffset ? 0 : state.offset; - let remainder = state.remainder; - let result = false; - - for (; offset < nodeValueLength; ++offset) { - const c = nodeValue[offset]; - if (!IGNORE_TEXT_PATTERN.test(c)) { - content += c; - if (--remainder <= 0) { - result = true; - ++offset; - break; - } - } - } - - state.offset = offset; - state.content = content; - state.remainder = remainder; - return result; - } - - static seekBackward(node, offset, length) { - const state = {node, offset, remainder: length, content: ''}; - if (length <= 0) { - return state; - } - - const TEXT_NODE = Node.TEXT_NODE; - const ELEMENT_NODE = Node.ELEMENT_NODE; - let resetOffset = false; - - const ruby = TextSourceRange.getRubyElement(node); - if (ruby !== null) { - node = ruby; - resetOffset = true; - } - - while (node !== null) { - let visitChildren = true; - const nodeType = node.nodeType; - - if (nodeType === TEXT_NODE) { - state.node = node; - if (TextSourceRange.seekBackwardTextNode(state, resetOffset)) { - break; - } - resetOffset = true; - } else if (nodeType === ELEMENT_NODE) { - visitChildren = TextSourceRange.shouldEnter(node); - } - - node = TextSourceRange.getPreviousNode(node, visitChildren); - } - - return state; - } - - static seekBackwardTextNode(state, resetOffset) { - const nodeValue = state.node.nodeValue; - let content = state.content; - let offset = resetOffset ? nodeValue.length : state.offset; - let remainder = state.remainder; - let result = false; - - for (; offset > 0; --offset) { - const c = nodeValue[offset - 1]; - if (!IGNORE_TEXT_PATTERN.test(c)) { - content = c + content; - if (--remainder <= 0) { - result = true; - --offset; - break; - } - } - } - - state.offset = offset; - state.content = content; - state.remainder = remainder; - return result; - } - static getParentElement(node) { while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { node = node.parentNode; @@ -290,66 +142,6 @@ class TextSourceRange { return writingMode; } } - - static getNodesInRange(range) { - const end = range.endContainer; - const nodes = []; - for (let node = range.startContainer; node !== null; node = TextSourceRange.getNextNode(node, true)) { - nodes.push(node); - if (node === end) { break; } - } - return nodes; - } - - static getNextNode(node, visitChildren) { - let next = visitChildren ? node.firstChild : null; - if (next === null) { - while (true) { - next = node.nextSibling; - if (next !== null) { break; } - - next = node.parentNode; - if (next === null) { break; } - - node = next; - } - } - return next; - } - - static getPreviousNode(node, visitChildren) { - let next = visitChildren ? node.lastChild : null; - if (next === null) { - while (true) { - next = node.previousSibling; - if (next !== null) { break; } - - next = node.parentNode; - if (next === null) { break; } - - node = next; - } - } - return next; - } - - static anyNodeMatchesSelector(nodeList, selector) { - for (const node of nodeList) { - if (TextSourceRange.nodeMatchesSelector(node, selector)) { - return true; - } - } - return false; - } - - static nodeMatchesSelector(node, selector) { - for (; node !== null; node = node.parentNode) { - if (node.nodeType === Node.ELEMENT_NODE) { - return node.matches(selector); - } - } - return false; - } } diff --git a/ext/manifest.json b/ext/manifest.json index c7aa50ce..3c5991c7 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Yomichan (development build)", - "version": "20.5.22.0", + "version": "20.6.27.0", "description": "Japanese dictionary with Anki integration. This extension is a pre-developmental build for testing purposes. The latest stable build can be found here: https://chrome.google.com/webstore/detail/yomichan/ogmnaimimemjmbakcfefmnahgdfhfami", "icons": { @@ -36,13 +36,13 @@ "matches": ["http://*/*", "https://*/*", "file://*/*"], "js": [ "mixed/js/core.js", + "mixed/js/comm.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/dom-text-scanner.js", "fg/js/popup.js", "fg/js/source.js", "fg/js/popup-factory.js", diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index 8b567173..703cef1c 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -27,13 +27,12 @@ --default-text-color: #333333; --light-text-color: #777777; --very-light-text-color: #999999; + --popuplar-kanji-text-color: #0275d8; --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; @@ -58,13 +57,12 @@ --default-text-color: #d4d4d4; --light-text-color: #888888; --very-light-text-color: #666666; + --popuplar-kanji-text-color: #0275d8; --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; @@ -173,14 +171,14 @@ h2 { display: flex; } -.navigation-header:not([data-has-previous=true]) .navigation-header-actions .action-previous>img, -.navigation-header:not([data-has-next=true]) .navigation-header-actions .action-next>img { +.navigation-header:not([data-has-previous=true]) .navigation-header-actions .action-button.action-previous:before, +.navigation-header:not([data-has-next=true]) .navigation-header-actions .action-button.action-next:before { opacity: 0.25; -webkit-filter: grayscale(100%); filter: grayscale(100%); } -.action-next>img { +.action-button.action-next:before { transform: scaleX(-1); } @@ -234,18 +232,18 @@ h2 { padding-right: 0.72em; } -.actions .disabled { +.action-button.disabled { pointer-events: none; cursor: default; } -.actions .disabled img { +.action-button.disabled:before { -webkit-filter: grayscale(100%); filter: grayscale(100%); opacity: 0.25; } -.actions .pending { +.action-button.pending { visibility: hidden; } @@ -274,10 +272,33 @@ button.action-button { cursor: pointer; } -.icon-image { +.action-button[data-icon]:before { + content: ""; width: 1.14285714em; /* 14px => 16px */ height: 1.14285714em; /* 14px => 16px */ display: block; + background-color: transparent; + background-repeat: no-repeat; + background-size: contain; +} + +.action-button[data-icon=entry-current]:before { + background-image: url("/mixed/img/entry-current.svg"); +} +.action-button[data-icon=view-note]:before { + background-image: url("/mixed/img/view-note.svg"); +} +.action-button[data-icon=add-term-kanji]:before { + background-image: url("/mixed/img/add-term-kanji.svg"); +} +.action-button[data-icon=add-term-kana]:before { + background-image: url("/mixed/img/add-term-kana.svg"); +} +.action-button[data-icon=play-audio]:before { + background-image: url("/mixed/img/play-audio.svg"); +} +.action-button[data-icon=source-term]:before { + background-image: url("/mixed/img/source-term.svg"); } .term-expression .kanji-link { @@ -296,7 +317,7 @@ button.action-button { color: var(--very-light-text-color); } -.entry:not(.entry-current) .current { +.entry:not(.entry-current) .action-current-indicator { display: none; } @@ -358,7 +379,7 @@ button.action-button { display: block; } -.tag-list>.tag:not(:last-child) { +.tag-list>.tag { margin-right: 0.375em; } @@ -505,7 +526,7 @@ button.action-button { } .term-definition-disambiguation-list:after { - content: " only)"; + content: " only) "; } .term-definition-disambiguation+.term-definition-disambiguation:before { diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html index fc0558a9..fed252a1 100644 --- a/ext/mixed/display-templates.html +++ b/ext/mixed/display-templates.html @@ -5,11 +5,11 @@ <div class="entry-header2"> <div class="entry-header3"> <div class="actions"> - <button class="action-button action-view-note pending disabled"><img src="/mixed/img/view-note.svg" class="icon-image" title="View added note (Alt + V)" alt></button> - <button class="action-button action-add-note pending disabled" data-mode="term-kanji"><img src="/mixed/img/add-term-kanji.svg" class="icon-image" title="Add expression (Alt + E)" alt></button> - <button class="action-button action-add-note pending disabled" data-mode="term-kana"><img src="/mixed/img/add-term-kana.svg" class="icon-image" title="Add reading (Alt + R)" alt></button> - <button class="action-button action-play-audio"><img src="/mixed/img/play-audio.svg" class="icon-image" title="Play audio (Alt + P)" alt></button> - <span class="action-button action-current-indicator"><img src="/mixed/img/entry-current.svg" class="icon-image current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)" alt></span> + <button class="action-button action-view-note pending disabled" data-icon="view-note" title="View added note (Alt + V)"></button> + <button class="action-button action-add-note pending disabled" data-icon="add-term-kanji" data-mode="term-kanji" title="Add expression (Alt + E)"></button> + <button class="action-button action-add-note pending disabled" data-icon="add-term-kana" data-mode="term-kana" title="Add reading (Alt + R)"></button> + <button class="action-button action-play-audio" data-icon="play-audio" title="Play audio (Alt + P)"></button> + <span class="action-button action-current-indicator" data-icon="entry-current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)"></span> </div> <div class="term-expression-list"></div> </div> @@ -24,15 +24,11 @@ <pre class="debug-info"></pre> </div></template> <template id="term-expression-template"><div class="term-expression"><span class="term-expression-text source-text"></span><div class="term-expression-details"> - <button class="action-button action-play-audio"><img src="/mixed/img/play-audio.svg" class="icon-image" title="Play audio" alt></button> + <button class="action-button action-play-audio" data-icon="play-audio" title="Play audio"></button> <div class="tags tag-list"></div> <div class="frequencies tag-list"></div> </div></div></template> -<template id="term-definition-item-template"><li class="term-definition-item"> - <div class="term-definition-tag-list tag-list"></div> - <div class="term-definition-disambiguation-list"></div> - <ul class="term-glossary-list"></ul> -</li></template> +<template id="term-definition-item-template"><li class="term-definition-item"><div class="term-definition-tag-list tag-list"></div><div class="term-definition-disambiguation-list"></div><ul class="term-glossary-list"></ul></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> @@ -55,9 +51,9 @@ <div class="entry-header2"> <div class="entry-header3"> <div class="actions"> - <button class="action-button action-view-note pending disabled"><img src="/mixed/img/view-note.svg" class="icon-image" title="View added note (Alt + V)" alt></button> - <button class="action-button action-add-note pending disabled" data-mode="kanji"><img src="/mixed/img/add-term-kanji.svg" class="icon-image" title="Add Kanji (Alt + K)" alt></button> - <span class="action-button action-current-indicator"><img src="/mixed/img/entry-current.svg" class="icon-image current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)" alt></span> + <button class="action-button action-view-note pending disabled" data-icon="view-note" title="View added note (Alt + V)"></button> + <button class="action-button action-add-note pending disabled" data-icon="add-term-kanji" data-mode="kanji" title="Add Kanji (Alt + K)"></button> + <span class="action-button action-current-indicator" data-icon="entry-current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)"></span> </div> <div class="kanji-glyph source-text"></div> </div> @@ -92,7 +88,7 @@ <template id="kanji-reading-template"><dd class="kanji-reading"></dd></template> <template id="tag-template"><span class="tag"><span class="tag-inner"></span></span></template> -<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="tag-inner"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></span></template> +<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="tag-inner"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></span></span></template> <template id="tag-search-template"><span class="tag" data-category="search"></span></template> </body></html> diff --git a/ext/mixed/img/backup.svg b/ext/mixed/img/backup.svg new file mode 100644 index 00000000..081560c2 --- /dev/null +++ b/ext/mixed/img/backup.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M6.5 1.25c-.13807.00001-.24999.11193-.25.25v6H4c-.22428-.00167-.33715.26997-.17773.42773l4 4c.09785.0989.25761.0989.35546 0l4-4c.15942-.15776.04655-.4294-.17773-.42773H9.75v-6c-.00001-.13807-.11193-.24999-.25-.25zM2 11v3.5h12V11h-1v1.5H3V11z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/book.svg b/ext/mixed/img/book.svg new file mode 100644 index 00000000..1b785296 --- /dev/null +++ b/ext/mixed/img/book.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M2 2.25L3.75.5H14v13l-1 1v-13H4.75l-1.04592 1H12v13H2z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/cog.svg b/ext/mixed/img/cog.svg new file mode 100644 index 00000000..7232d25d --- /dev/null +++ b/ext/mixed/img/cog.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M7 .5c-.13807 0-.26165.11066-.2754.24805l-.15624 1.55468c-.02198.21982-.2122.44631-.41992.52149a5.5035 5.5035 0 00-.4961.20703c-.19937.0951-.49501.0715-.66601-.0684l-1.2129-.99219c-.10685-.0874-.2715-.0781-.36913.0195L1.99023 3.40423c-.09763.0976-.10696.26228-.01953.36914l.99024 1.21094c.13984.17101.16424.46606.07031.66601a5.4994 5.4994 0 00-.20313.49805c-.07396.20816-.2997.398-.51953.41992l-1.56054.15625C.61066 6.73835.5 6.86193.5 7v2c0 .13807.11066.26165.24805.27539l1.55468.15625c.21982.022.4463.21219.52149.41992a5.49997 5.49997 0 00.20703.4961c.09514.19937.0715.49501-.06836.66601l-.99219 1.21289c-.08743.10686-.0781.27151.01953.36914l1.41407 1.41407c.09763.0976.26228.10696.36914.0195l1.21093-.99024c.17102-.13984.46607-.16425.66602-.0703.1626.0758.32882.14358.49805.20313.20816.074.398.2997.41992.51953l.15625 1.56055c.01374.13737.13732.24804.27539.24804h2c.13807 0 .26165-.11066.2754-.24805l.15624-1.55468c.02198-.21982.2122-.44631.41992-.52149a5.50632 5.50632 0 00.4961-.20703c.19937-.0952.49502-.0715.66601.0684l1.2129.99219c.10685.0874.2715.0781.36913-.0195l1.41407-1.41406c.09763-.0976.10696-.26229.01953-.36915l-.99024-1.21093c-.13984-.17102-.16424-.46607-.0703-.66602a5.49962 5.49962 0 00.20312-.49805c.07396-.20816.2997-.398.51953-.41992l1.56054-.15625C15.38935 9.26165 15.5 9.13807 15.5 9V7c0-.13807-.11066-.26165-.24805-.27539l-1.55468-.15625c-.21982-.022-.44631-.21219-.52149-.41992a5.50036 5.50036 0 00-.20703-.4961c-.09515-.19937-.07151-.49501.06836-.66601l.99219-1.21289c.08743-.10686.0781-.27151-.01953-.36914L12.5957 1.99023c-.09763-.0976-.26227-.10696-.36914-.0195l-1.21093.99024c-.17102.13984-.46607.16424-.66602.0703a5.4957 5.4957 0 00-.49805-.20312c-.20816-.074-.398-.29971-.41992-.51954L9.2754.74807C9.26165.61066 9.13807.5 9 .5zm1 4.75A2.75 2.75 0 0110.75 8 2.75 2.75 0 018 10.75 2.75 2.75 0 015.25 8 2.75 2.75 0 018 5.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/hiragana-a.svg b/ext/mixed/img/hiragana-a.svg new file mode 100644 index 00000000..1a7d6a7f --- /dev/null +++ b/ext/mixed/img/hiragana-a.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M2.42064 2.88601h9.52827M8.92775 14.04106c4.64794-.2324 5.11273-2.78876 5.12992-4.21415.01933-1.60201-1.62623-3.97684-5.22508-4.05209-4.78517-.10004-8.34375 4.35907-6.4074 6.96794 1.72067 2.31827 6.03777-.32848 7.43215-6.3708M5.67419 1.02685c-.2324 6.7395.06942 8.31184.4648 11.61984" fill="none" stroke="#333" stroke-width="1.85917" stroke-linejoin="round"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/keyboard.svg b/ext/mixed/img/keyboard.svg new file mode 100644 index 00000000..b94afde5 --- /dev/null +++ b/ext/mixed/img/keyboard.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M7.99268 1.43018a.4688.4688 0 00-.46143.47607v3.75H1.4375c-.51938 0-.9375.41812-.9375.9375v6.09375c0 .51938.41813.9375.9375.9375h13.125c.51938 0 .9375-.41813.9375-.9375V6.59375c0-.51938-.41813-.9375-.9375-.9375H8.46875v-3.75a.4688.4688 0 00-.47607-.47607zM1.78906 7.0625h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H1.78907a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H3.66407a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H5.53907a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719H7.41406a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719H9.28906a.11693.11693 0 01-.11718-.11719V7.1797a.11693.11693 0 01.11718-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719h-1.17188a.11693.11693 0 01-.11718-.11719V7.1797a.11693.11693 0 01.11718-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719h-1.17188a.11693.11693 0 01-.11718-.11719V7.1797a.11693.11693 0 01.11718-.11719zm-11.25 1.875h2.10938a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H1.78907a.11693.11693 0 01-.11719-.11719V9.0547a.11693.11693 0 01.1172-.11719zm2.8125 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H4.60157a.11693.11693 0 01-.11719-.11719V9.0547a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H6.47657a.11693.11693 0 01-.11719-.11719V9.0547a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719H8.35156a.11693.11693 0 01-.11718-.11719V9.0547a.11693.11693 0 01.11718-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-1.17188a.11694.11694 0 01-.11718-.11719V9.0547a.11693.11693 0 01.11718-.11719zm1.875 0h2.10938a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-2.10938a.11694.11694 0 01-.11718-.11719V9.0547a.11693.11693 0 01.11718-.11719zm-10.3125 1.875h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H1.78907a.11693.11693 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H3.66407a.11693.11693 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm1.875 0h4.92188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719H5.53906a.11693.11693 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm5.625 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-1.17188a.11694.11694 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-1.17188a.11694.11694 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/left-chevron.svg b/ext/mixed/img/left-chevron.svg new file mode 100644 index 00000000..9dd012dc --- /dev/null +++ b/ext/mixed/img/left-chevron.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M9.93945 15.06054l2.1211-2.12109L7.12109 8l4.93946-4.93946L9.93945.93945 2.87891 8z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/lock.svg b/ext/mixed/img/lock.svg new file mode 100644 index 00000000..7707ba98 --- /dev/null +++ b/ext/mixed/img/lock.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1.5c-1.786 0-3.25 1.464-3.25 3.25V7.5H4c-.55226.00006-.99994.44774-1 1v5c.00006.55226.44774.99995 1 1h8c.55226-.00005.99994-.44774 1-1v-5c-.00006-.55226-.44774-.99994-1-1h-.75V4.75C11.25 2.964 9.786 1.5 8 1.5zM8 3c.97538 0 1.75.77462 1.75 1.75V7.5h-3.5V4.75C6.25 3.77462 7.02462 3 8 3z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/magnifying-glass.svg b/ext/mixed/img/magnifying-glass.svg new file mode 100644 index 00000000..a8367d8d --- /dev/null +++ b/ext/mixed/img/magnifying-glass.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M10.781 9.7188l-1.0312.03125-.03125 1.0312 4.5 4.5c.70833.7085 1.7708-.354 1.0625-1.0625zM5.7498.5C2.8592.5.4998 2.8594.4998 5.75S2.8592 11 5.7498 11s5.25-2.3594 5.25-5.25S8.6404.5 5.7498.5zm0 1.5c2.08 0 3.75 1.67 3.75 3.75s-1.67 3.75-3.75 3.75-3.75-1.67-3.75-3.75S3.6698 2 5.7498 2z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/note-card.svg b/ext/mixed/img/note-card.svg new file mode 100644 index 00000000..fb00b074 --- /dev/null +++ b/ext/mixed/img/note-card.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 .5c-.82235 0-1.5.67765-1.5 1.5v12c0 .82235.67765 1.5 1.5 1.5h8c.82235 0 1.5-.67765 1.5-1.5V2c0-.82235-.67765-1.5-1.5-1.5zm6.75977 1.875c.19735.005.35705.16213.36523.35937l.04883 1.125.81055.78125c.19826.1921.12805.52533-.13086.6211l-1.0547.39258-.49218 1.01367c-.12112.24791-.45945.28457-.63086.0684l-.69922-.88086-1.11523-.15625c-.27337-.0377-.41375-.34782-.26172-.57813l.62305-.93945-.19727-1.10742c-.04931-.27269.20396-.50285.4707-.42774l1.08399.30274.99414-.5293a.37513.37513 0 01.18555-.0449zM4 9h8v1H4zm0 2h8v1H4zm0 2h6v1H4z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/palette.svg b/ext/mixed/img/palette.svg new file mode 100644 index 00000000..4a615ef2 --- /dev/null +++ b/ext/mixed/img/palette.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 .5C3.85786.5.5 3.85786.5 8c0 4.14214 3.35786 7.5 7.5 7.5 2.5209 0 1.25017-2.49957.47852-3.63867-.80904-1.19429.27195-1.86152 1.3828-1.36524C13.04713 11.91938 15.5 10.75 15.5 8 15.5 3.85786 12.14214.5 8 .5zm-2 2c.69036 0 1.25.55964 1.25 1.25S6.69036 5 6 5s-1.25-.55964-1.25-1.25S5.30964 2.5 6 2.5zm4 0c.69036 0 1.25.55964 1.25 1.25S10.69036 5 10 5s-1.25-.55964-1.25-1.25S9.30964 2.5 10 2.5zm-6.75 3c.69036 0 1.25.55964 1.25 1.25S3.94036 8 3.25 8 2 7.44036 2 6.75 2.55964 5.5 3.25 5.5zm9.5 0c.69036 0 1.25.55964 1.25 1.25S13.44036 8 12.75 8s-1.25-.55964-1.25-1.25.55964-1.25 1.25-1.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/popup.svg b/ext/mixed/img/popup.svg new file mode 100644 index 00000000..ef528cfb --- /dev/null +++ b/ext/mixed/img/popup.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1.25 2c-.4155 0-.75.3345-.75.75v8.5c0 .4155.3345.75.75.75H2.5v2.75L4.75 12h9.75c.4155 0 .75-.3345.75-.75v-8.5c0-.4155-.3345-.75-.75-.75zM2 3.5h3.5v1.75H2zM2 7h12v1H2zm0 2h10v1H2z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/profile.svg b/ext/mixed/img/profile.svg new file mode 100644 index 00000000..52a1363d --- /dev/null +++ b/ext/mixed/img/profile.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M5.5039 9.3262c-.85148.2284-1.632.5678-2.3008 1.0137C1.922 11.19398 1 12.4902 1 14.0001c.00006.55226.44774.99994 1 1h12c.55226-.00006.99994-.44774 1-1 0-1.5099-.922-2.8061-2.2031-3.6602-.66975-.4465-1.4518-.78528-2.3047-1.0137A5 5 0 018 10.00003a5 5 0 01-2.4961-.67383zM12 5a4 4 0 01-4 4 4 4 0 01-4-4 4 4 0 014-4 4 4 0 014 4z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/question-mark-circle.svg b/ext/mixed/img/question-mark-circle.svg new file mode 100644 index 00000000..0076f7cd --- /dev/null +++ b/ext/mixed/img/question-mark-circle.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1a7 7 0 00-7 7 7 7 0 007 7 7 7 0 007-7 7 7 0 00-7-7zm0 1.54687c2.26411 0 4.28516 1.56084 4.28516 3.70118 0 1.49831-1.02674 2.73602-2.375 3.31445-.2834.12158-.5242.29226-.68946.4707-.16525.17845-.2461.35932-.2461.49805 0 .10756-.08775.1953-.1953.19531H7.2207c-.10755-.00001-.1953-.0878-.1953-.19531 0-.70384.2822-1.29397.68554-1.75195.40334-.45799.9275-.79069 1.4336-1.00782.77637-.33308 1.19335-.91838 1.19335-1.52343 0-.85756-.93956-1.75391-2.33789-1.75391-1.39833 0-2.3379.89635-2.3379 1.75391 0 .10755-.0858.19335-.19335.19336h-1.5586c-.10755-.00001-.1953-.0858-.1953-.19336 0-2.14034 2.02104-3.70118 4.28515-3.70118zm-.7793 8.95899h1.5586c.10755.00001.1953.0878.1953.19531v1.55664c0 .10756-.08775.1953-.1953.19531H7.2207c-.10755-.00001-.1953-.0878-.1953-.19531v-1.55664c0-.10756.08775-.1953.1953-.19531z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/question-mark-thick.svg b/ext/mixed/img/question-mark-thick.svg new file mode 100644 index 00000000..7f2214a6 --- /dev/null +++ b/ext/mixed/img/question-mark-thick.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1C5.09367 1 2.5 3.00255 2.5 5.75c.00001.13807.11193.24999.25.25h2c.13807-.00001.24999-.11193.25-.25C5 4.6492 6.20504 3.5 8 3.5s3 1.1492 3 2.25c0 .77668-.53465 1.52947-1.53125 1.95703C8.8191 7.98574 8.14666 8.41211 7.62891 9c-.51776.58789-.87891 1.34652-.87891 2.25.00001.13806.11193.24999.25.25h2c.13807-.00001.24999-.11194.25-.25 0-.17808.10428-.41156.3164-.64063.21213-.22906.52294-.44744.88672-.60351C12.18383 9.26336 13.5 7.67331 13.5 5.75 13.5 3.00255 10.90633 1 8 1zM7 12.5c-.13807.00001-.24999.11194-.25.25v2c.00001.13806.11193.24999.25.25h2c.13807-.00001.24999-.11194.25-.25v-2c-.00001-.13806-.11193-.24999-.25-.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/question-mark.svg b/ext/mixed/img/question-mark.svg new file mode 100644 index 00000000..bc3b9a1c --- /dev/null +++ b/ext/mixed/img/question-mark.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1.5c-2.28226 0-4.25 1.62278-4.25 3.75.00001.13807.11193.24999.25.25h1c.13807-.00001.24999-.11193.25-.25C5.25 4.06973 6.4121 3 8 3s2.75 1.06973 2.75 2.25c0 .7462-.43551 1.42101-1.1836 1.84766-.52876.30156-1.0814.69072-1.52538 1.20117C7.59525 8.81133 7.25 9.47585 7.25 10.25v1c.00001.13806.11193.24999.25.25h1c.13807-.00001.24999-.11194.25-.25v-1c0-.33249.14065-.63898.42578-.9668.2828-.32513.70214-.6383 1.13086-.8828C11.44681 7.75013 12.25 6.59134 12.25 5.25c0-2.12722-1.96775-3.75-4.25-3.75zM7.5 13c-.13807.00001-.24999.11194-.25.25v1c.00001.13806.11193.24999.25.25h1c.13807-.00001.24999-.11194.25-.25v-1c-.00001-.13806-.11193-.24999-.25-.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/right-chevron.svg b/ext/mixed/img/right-chevron.svg new file mode 100644 index 00000000..e210057b --- /dev/null +++ b/ext/mixed/img/right-chevron.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M6.06055.93945l-2.1211 2.12109L8.87891 8l-4.93946 4.93945 2.1211 2.12109L13.12109 8z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/scanning.svg b/ext/mixed/img/scanning.svg new file mode 100644 index 00000000..9ac16c83 --- /dev/null +++ b/ext/mixed/img/scanning.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M.5 1.5V3h.75c.42847 0 .75.32153.75.75v8.5c0 .42847-.32153.75-.75.75H.5v1.5h.75c.57623 0 1.1-.2277 1.5-.58984.4.36214.92377.58984 1.5.58984H5V13h-.75c-.42847 0-.75-.32153-.75-.75v-8.5c0-.42847.32153-.75.75-.75H5V1.5h-.75c-.57623 0-1.1.2277-1.5.58984C2.35 1.7277 1.82623 1.5 1.25 1.5zm11.2461 2.75c-.13654.00215-.2461.11345-.2461.25v2H8v3h3.5v2c-.0017.22428.26997.33715.42773.17773l3.5-3.5c.0989-.09785.0989-.25761 0-.35546l-3.5-3.5A.25002.25002 0 0011.7461 4.25zM5 6.5h1.5v3H5z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/speaker.svg b/ext/mixed/img/speaker.svg new file mode 100644 index 00000000..4c9b8eba --- /dev/null +++ b/ext/mixed/img/speaker.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M12.25 2.75c1.5 3 1.5 7.5 0 10.5 4.25-1.75 4.25-8.75 0-10.5m-2.5 2c.75 1.5.75 5 0 6.5 3-1.25 3-5.25 0-6.5m-1.75781-2.5a.25001.25001 0 00-.1543.06055L4.4082 5.25H.75c-.13807.00001-.24999.11193-.25.25V8a.25002.25002 0 000 .0254V10.5c.00001.13806.11193.24999.25.25h3.6582l3.4297 2.93945c.16201.13795.41132.02334.4121-.18945v-11c.00005-.14117-.1167-.2544-.25781-.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/text-parsing.svg b/ext/mixed/img/text-parsing.svg new file mode 100644 index 00000000..dfa88af8 --- /dev/null +++ b/ext/mixed/img/text-parsing.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M.5 2v2.5h.25c0-.277.223-.5.5-.5H3.5c.277 0 .5.223.5.5v8.75c0 .277-.223.5-.5.5V14h3v-.25c-.277 0-.5-.223-.5-.5V4.5c0-.277.223-.5.5-.5h2.25c.277 0 .5.223.5.5h.25V2zm7 3.5v1h8v-1zm0 2v1h5v-1zm6 0v1h2v-1zm-6 2v1h2v-1zm3 0v1h3v-1zm4 0v1h1v-1zm-7 2v1h1v-1zm2 0v1H12v-1z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/translation.svg b/ext/mixed/img/translation.svg new file mode 100644 index 00000000..fdb98b1d --- /dev/null +++ b/ext/mixed/img/translation.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1.35873 1.65912H6.75M5.04057 7.97085c2.6299-.13149 2.89288-1.57793 2.9026-2.38444.01094-.90645-.92014-2.25017-2.95644-2.29275C2.2792 3.23706.2657 5.7601 1.36131 7.23625c.97358 1.31172 3.41628-.18586 4.20524-3.60471M3.19965.60717c-.13149 3.81333.03928 4.70299.263 6.57471" fill="none" stroke="#333" stroke-width="1.25" stroke-linejoin="round"/><path d="M9.5 5.5l-4 10h2l1.0996-2.75h3.8008L13.5 15.5h2l-4-10zm1 2.5l1.0996 2.75H9.4004z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 0bc91759..5c17d50e 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -15,307 +15,341 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/* global + * CrossFrameAPI + */ + +const api = (() => { + class API { + constructor() { + this._forwardLogsToBackendEnabled = false; + this._crossFrame = new CrossFrameAPI(); + } + + get crossFrame() { + return this._crossFrame; + } + + prepare() { + this._crossFrame.prepare(); + } + + forwardLogsToBackend() { + if (this._forwardLogsToBackendEnabled) { return; } + this._forwardLogsToBackendEnabled = true; + + yomichan.on('log', async ({error, level, context}) => { + try { + await this.log(errorToJson(error), level, context); + } catch (e) { + // NOP + } + }); + } + + // Invoke functions + + optionsSchemaGet() { + return this._invoke('optionsSchemaGet'); + } + + optionsGet(optionsContext) { + return this._invoke('optionsGet', {optionsContext}); + } + + optionsGetFull() { + return this._invoke('optionsGetFull'); + } + + optionsSave(source) { + return this._invoke('optionsSave', {source}); + } + + termsFind(text, details, optionsContext) { + return this._invoke('termsFind', {text, details, optionsContext}); + } + + textParse(text, optionsContext) { + return this._invoke('textParse', {text, optionsContext}); + } + + kanjiFind(text, optionsContext) { + return this._invoke('kanjiFind', {text, optionsContext}); + } + + definitionAdd(definition, mode, context, details, optionsContext) { + return this._invoke('definitionAdd', {definition, mode, context, details, optionsContext}); + } + + definitionsAddable(definitions, modes, context, optionsContext) { + return this._invoke('definitionsAddable', {definitions, modes, context, optionsContext}); + } + + noteView(noteId) { + return this._invoke('noteView', {noteId}); + } + + templateRender(template, data) { + return this._invoke('templateRender', {data, template}); + } + + audioGetUri(definition, source, details) { + return this._invoke('audioGetUri', {definition, source, details}); + } + + commandExec(command, params) { + return this._invoke('commandExec', {command, params}); + } + + screenshotGet(options) { + return this._invoke('screenshotGet', {options}); + } + + sendMessageToFrame(frameId, action, params) { + return this._invoke('sendMessageToFrame', {frameId, action, params}); + } + + broadcastTab(action, params) { + return this._invoke('broadcastTab', {action, params}); + } + + frameInformationGet() { + return this._invoke('frameInformationGet'); + } + + injectStylesheet(type, value) { + return this._invoke('injectStylesheet', {type, value}); + } + + getStylesheetContent(url) { + return this._invoke('getStylesheetContent', {url}); + } + + getEnvironmentInfo() { + return this._invoke('getEnvironmentInfo'); + } + + clipboardGet() { + return this._invoke('clipboardGet'); + } -function apiOptionsSchemaGet() { - return _apiInvoke('optionsSchemaGet'); -} + getDisplayTemplatesHtml() { + return this._invoke('getDisplayTemplatesHtml'); + } + + getQueryParserTemplatesHtml() { + return this._invoke('getQueryParserTemplatesHtml'); + } + + getZoom() { + return this._invoke('getZoom'); + } + + getDefaultAnkiFieldTemplates() { + return this._invoke('getDefaultAnkiFieldTemplates'); + } + + getAnkiDeckNames() { + return this._invoke('getAnkiDeckNames'); + } + + getAnkiModelNames() { + return this._invoke('getAnkiModelNames'); + } + + getAnkiModelFieldNames(modelName) { + return this._invoke('getAnkiModelFieldNames', {modelName}); + } + + getDictionaryInfo() { + return this._invoke('getDictionaryInfo'); + } + + getDictionaryCounts(dictionaryNames, getTotal) { + return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}); + } -function apiOptionsGet(optionsContext) { - return _apiInvoke('optionsGet', {optionsContext}); -} + purgeDatabase() { + return this._invoke('purgeDatabase'); + } -function apiOptionsGetFull() { - return _apiInvoke('optionsGetFull'); -} + getMedia(targets) { + return this._invoke('getMedia', {targets}); + } + + log(error, level, context) { + return this._invoke('log', {error, level, context}); + } -function apiOptionsSave(source) { - return _apiInvoke('optionsSave', {source}); -} + logIndicatorClear() { + return this._invoke('logIndicatorClear'); + } -function apiTermsFind(text, details, optionsContext) { - return _apiInvoke('termsFind', {text, details, optionsContext}); -} + modifySettings(targets, source) { + return this._invoke('modifySettings', {targets, source}); + } -function apiTextParse(text, optionsContext) { - return _apiInvoke('textParse', {text, optionsContext}); -} + getSettings(targets) { + return this._invoke('getSettings', {targets}); + } -function apiKanjiFind(text, optionsContext) { - return _apiInvoke('kanjiFind', {text, optionsContext}); -} + setAllSettings(value, source) { + return this._invoke('setAllSettings', {value, source}); + } -function apiDefinitionAdd(definition, mode, context, details, optionsContext) { - return _apiInvoke('definitionAdd', {definition, mode, context, details, optionsContext}); -} + // Invoke functions with progress -function apiDefinitionsAddable(definitions, modes, context, optionsContext) { - return _apiInvoke('definitionsAddable', {definitions, modes, context, optionsContext}); -} + importDictionaryArchive(archiveContent, details, onProgress) { + return this._invokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress); + } -function apiNoteView(noteId) { - return _apiInvoke('noteView', {noteId}); -} + deleteDictionary(dictionaryName, onProgress) { + return this._invokeWithProgress('deleteDictionary', {dictionaryName}, onProgress); + } -function apiTemplateRender(template, data) { - return _apiInvoke('templateRender', {data, template}); -} + // Utilities -function apiAudioGetUri(definition, source, details) { - return _apiInvoke('audioGetUri', {definition, source, details}); -} + _createActionPort(timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let portNameResolve; + let portNameReject; + const portNamePromise = new Promise((resolve2, reject2) => { + portNameResolve = resolve2; + portNameReject = reject2; + }); -function apiCommandExec(command, params) { - return _apiInvoke('commandExec', {command, params}); -} - -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}); -} - -function apiFrameInformationGet() { - return _apiInvoke('frameInformationGet'); -} - -function apiInjectStylesheet(type, value) { - return _apiInvoke('injectStylesheet', {type, value}); -} - -function apiGetEnvironmentInfo() { - return _apiInvoke('getEnvironmentInfo'); -} - -function apiClipboardGet() { - return _apiInvoke('clipboardGet'); -} - -function apiGetDisplayTemplatesHtml() { - return _apiInvoke('getDisplayTemplatesHtml'); -} - -function apiGetQueryParserTemplatesHtml() { - return _apiInvoke('getQueryParserTemplatesHtml'); -} - -function apiGetZoom() { - return _apiInvoke('getZoom'); -} - -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': + 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; } - break; - case 'progress': - try { - onProgress(...message.data); - } catch (e) { - // NOP + chrome.runtime.onConnect.removeListener(onConnect); + portNameReject(e); + reject(e); + }; + + timer = setTimeout(() => onError(new Error('Timeout')), timeout); + + chrome.runtime.onConnect.addListener(onConnect); + this._invoke('createActionPort').then(portNameResolve, onError); + }); + } + + _invokeWithProgress(action, params, onProgress, timeout=5000) { + return new Promise((resolve, reject) => { + let port = null; + + if (typeof onProgress !== 'function') { + onProgress = () => {}; + } + + const onMessage = (message) => { + switch (message.type) { + 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; } - break; - case 'complete': - cleanup(); - resolve(message.data); - break; - case 'error': + }; + + const onDisconnect = () => { 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) => { - try { - chrome.runtime.sendMessage(data, (response) => { - _apiCheckLastError(chrome.runtime.lastError); - if (response !== null && typeof response === 'object') { - if (typeof response.error !== 'undefined') { - reject(jsonToError(response.error)); - } else { - resolve(response.result); + reject(new Error('Disconnected')); + }; + + const cleanup = () => { + if (port !== null) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + port = null; + } + onProgress = null; + }; + + (async () => { + try { + port = await this._createActionPort(timeout); + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + + // Chrome has a maximum message size that can be sent, so longer messages must be fragmented. + const messageString = JSON.stringify({action, params}); + const fragmentSize = 1e7; // 10 MB + for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) { + const data = messageString.substring(i, i + fragmentSize); + port.postMessage({action: 'fragment', data}); + } + port.postMessage({action: 'invoke'}); + } catch (e) { + cleanup(); + reject(e); + } finally { + action = null; + params = null; } - } else { - const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; - reject(new Error(`${message} (${JSON.stringify(data)})`)); + })(); + }); + } + + _invoke(action, params={}) { + const data = {action, params}; + return new Promise((resolve, reject) => { + try { + chrome.runtime.sendMessage(data, (response) => { + this._checkLastError(chrome.runtime.lastError); + if (response !== null && typeof response === 'object') { + if (typeof response.error !== 'undefined') { + reject(jsonToError(response.error)); + } else { + resolve(response.result); + } + } else { + const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; + reject(new Error(`${message} (${JSON.stringify(data)})`)); + } + }); + } catch (e) { + reject(e); + yomichan.triggerOrphaned(e); } }); - } catch (e) { - reject(e); - yomichan.triggerOrphaned(e); - } - }); -} - -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) { + } + + _checkLastError() { // NOP } - }); -} + } + + // eslint-disable-next-line no-shadow + const api = new API(); + api.prepare(); + return api; +})(); diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js index fdfb0b10..c590b909 100644 --- a/ext/mixed/js/audio-system.js +++ b/ext/mixed/js/audio-system.js @@ -169,22 +169,22 @@ 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(); + async _createAudioBinaryFromUrl(url) { + const response = await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' }); + const arrayBuffer = await response.arrayBuffer(); + + if (!await this._isAudioBinaryValid(arrayBuffer)) { + throw new Error('Could not retrieve audio'); + } + + return arrayBuffer; } _isAudioValid(audio) { diff --git a/ext/mixed/js/comm.js b/ext/mixed/js/comm.js new file mode 100644 index 00000000..7787616e --- /dev/null +++ b/ext/mixed/js/comm.js @@ -0,0 +1,282 @@ +/* + * 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 CrossFrameAPIPort extends EventDispatcher { + constructor(otherFrameId, port, messageHandlers) { + super(); + this._otherFrameId = otherFrameId; + this._port = port; + this._messageHandlers = messageHandlers; + this._activeInvocations = new Map(); + this._invocationId = 0; + this._eventListeners = new EventListenerCollection(); + } + + get otherFrameId() { + return this._otherFrameId; + } + + prepare() { + this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); + this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); + } + + invoke(action, params, ackTimeout, responseTimeout) { + return new Promise((resolve, reject) => { + if (this._port === null) { + reject(new Error('Port is disconnected')); + return; + } + + const id = this._invocationId++; + const invocation = {id, resolve, reject, responseTimeout, ack: false, timer: null}; + this._activeInvocations.set(id, invocation); + + if (ackTimeout !== null) { + try { + invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (ack)')), ackTimeout); + } catch (e) { + this._onError(id, new Error('Failed to set timeout')); + return; + } + } + + try { + this._port.postMessage({type: 'invoke', id, data: {action, params}}); + } catch (e) { + this._onError(id, e); + } + }); + } + + disconnect() { + this._onDisconnect(); + } + + // Private + + _onDisconnect() { + if (this._port === null) { return; } + this._eventListeners.removeAllEventListeners(); + this._port = null; + for (const id of this._activeInvocations.keys()) { + this._onError(id, new Error('Disconnected')); + } + this.trigger('disconnect', this); + } + + _onMessage({type, id, data}) { + switch (type) { + case 'invoke': + this._onInvoke(id, data); + break; + case 'ack': + this._onAck(id); + break; + case 'result': + this._onResult(id, data); + break; + } + } + + // Response handlers + + _onAck(id) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { + yomichan.logWarning(new Error(`Request ${id} not found for ack`)); + return; + } + + if (invocation.ack) { + this._onError(id, new Error(`Request ${id} already ack'd`)); + return; + } + + invocation.ack = true; + + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + + const responseTimeout = invocation.responseTimeout; + if (responseTimeout !== null) { + try { + invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (response)')), responseTimeout); + } catch (e) { + this._onError(id, new Error('Failed to set timeout')); + } + } + } + + _onResult(id, data) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { + yomichan.logWarning(new Error(`Request ${id} not found`)); + return; + } + + if (!invocation.ack) { + this._onError(id, new Error(`Request ${id} not ack'd`)); + return; + } + + this._activeInvocations.delete(id); + + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + + const error = data.error; + if (typeof error !== 'undefined') { + invocation.reject(jsonToError(error)); + } else { + invocation.resolve(data.result); + } + } + + _onError(id, error) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { return; } + + this._activeInvocations.delete(id); + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + invocation.reject(error); + } + + // Invocation + + _onInvoke(id, {action, params}) { + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { + this._sendError(id, new Error(`Unknown action: ${action}`)); + return; + } + + const {handler, async} = messageHandler; + + this._sendAck(id); + if (async) { + this._invokeHandlerAsync(id, handler, params); + } else { + this._invokeHandler(id, handler, params); + } + } + + _invokeHandler(id, handler, params) { + try { + const result = handler(params); + this._sendResult(id, result); + } catch (error) { + this._sendError(id, error); + } + } + + async _invokeHandlerAsync(id, handler, params) { + try { + const result = await handler(params); + this._sendResult(id, result); + } catch (error) { + this._sendError(id, error); + } + } + + _sendResponse(data) { + if (this._port === null) { return; } + try { + this._port.postMessage(data); + } catch (e) { + // NOP + } + } + + _sendAck(id) { + this._sendResponse({type: 'ack', id}); + } + + _sendResult(id, result) { + this._sendResponse({type: 'result', id, data: {result}}); + } + + _sendError(id, error) { + this._sendResponse({type: 'result', id, data: {error: errorToJson(error)}}); + } +} + +class CrossFrameAPI { + constructor() { + this._ackTimeout = 3000; // 3 seconds + this._responseTimeout = 10000; // 10 seconds + this._commPorts = new Map(); + this._messageHandlers = new Map(); + this._onDisconnectBind = this._onDisconnect.bind(this); + } + + prepare() { + chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); + } + + async invoke(targetFrameId, action, params={}) { + const commPort = this._getOrCreateCommPort(targetFrameId); + return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); + } + + registerHandlers(messageHandlers) { + for (const [key, value] of messageHandlers) { + if (this._messageHandlers.has(key)) { + throw new Error(`Handler ${key} is already registered`); + } + this._messageHandlers.set(key, value); + } + } + + _onConnect(port) { + const match = /^cross-frame-communication-port-(\d+)$/.exec(`${port.name}`); + if (match === null) { return; } + + const otherFrameId = parseInt(match[1], 10); + this._setupCommPort(otherFrameId, port); + } + + _onDisconnect(commPort) { + commPort.off('disconnect', this._onDisconnectBind); + this._commPorts.delete(commPort.otherFrameId); + } + + _getOrCreateCommPort(otherFrameId) { + const commPort = this._commPorts.get(otherFrameId); + return (typeof commPort !== 'undefined' ? commPort : this._createCommPort(otherFrameId)); + } + + _createCommPort(otherFrameId) { + const port = chrome.runtime.connect(null, {name: `background-cross-frame-communication-port-${otherFrameId}`}); + return this._setupCommPort(otherFrameId, port); + } + + _setupCommPort(otherFrameId, port) { + const commPort = new CrossFrameAPIPort(otherFrameId, port, this._messageHandlers); + this._commPorts.set(otherFrameId, commPort); + commPort.prepare(); + commPort.on('disconnect', this._onDisconnectBind); + return commPort; + } +} diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 589425f2..bf877e72 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -164,7 +164,10 @@ function getSetDifference(set1, set2) { function promiseTimeout(delay, resolveValue) { if (delay <= 0) { - return Promise.resolve(resolveValue); + const promise = Promise.resolve(resolveValue); + promise.resolve = () => {}; // NOP + promise.reject = () => {}; // NOP + return promise; } let timer = null; @@ -174,7 +177,7 @@ function promiseTimeout(delay, resolveValue) { const complete = (callback, value) => { if (callback === null) { return; } if (timer !== null) { - window.clearTimeout(timer); + clearTimeout(timer); timer = null; } promiseResolve = null; @@ -189,7 +192,7 @@ function promiseTimeout(delay, resolveValue) { promiseResolve = resolve2; promiseReject = reject2; }); - timer = window.setTimeout(() => { + timer = setTimeout(() => { timer = null; resolve(resolveValue); }, delay); @@ -255,15 +258,37 @@ class EventListenerCollection { return this._eventListeners.length; } - addEventListener(node, type, listener, options) { - node.addEventListener(type, listener, options); - this._eventListeners.push([node, type, listener, options]); + addEventListener(object, ...args) { + object.addEventListener(...args); + this._eventListeners.push(['removeEventListener', object, ...args]); + } + + addListener(object, ...args) { + object.addListener(...args); + this._eventListeners.push(['removeListener', object, ...args]); + } + + on(object, ...args) { + object.on(...args); + this._eventListeners.push(['off', object, ...args]); } removeAllEventListeners() { if (this._eventListeners.length === 0) { return; } - for (const [node, type, listener, options] of this._eventListeners) { - node.removeEventListener(type, listener, options); + for (const [removeFunctionName, object, ...args] of this._eventListeners) { + switch (removeFunctionName) { + case 'removeEventListener': + object.removeEventListener(...args); + break; + case 'removeListener': + object.removeListener(...args); + break; + case 'off': + object.off(...args); + break; + default: + throw new Error(`Unknown remove function: ${removeFunctionName}`); + } } this._eventListeners = []; } @@ -306,7 +331,7 @@ const yomichan = (() => { generateId(length) { const array = new Uint8Array(length); - window.crypto.getRandomValues(array); + crypto.getRandomValues(array); let id = ''; for (const value of array) { id += value.toString(16).padStart(2, '0'); @@ -339,7 +364,7 @@ const yomichan = (() => { const runtimeMessageCallback = ({action, params}, sender, sendResponse) => { let timeoutId = null; if (timeout !== null) { - timeoutId = window.setTimeout(() => { + timeoutId = setTimeout(() => { timeoutId = null; eventHandler.removeListener(runtimeMessageCallback); reject(new Error(`Listener timed out in ${timeout} ms`)); @@ -348,7 +373,7 @@ const yomichan = (() => { const cleanupResolve = (value) => { if (timeoutId !== null) { - window.clearTimeout(timeoutId); + clearTimeout(timeoutId); timeoutId = null; } eventHandler.removeListener(runtimeMessageCallback); @@ -428,10 +453,12 @@ const yomichan = (() => { // Private + _getUrl() { + return (typeof window === 'object' && window !== null ? window.location.href : ''); + } + _getLogContext() { - return { - url: window.location.href - }; + return {url: this._getUrl()}; } _onMessage({action, params}, sender, callback) { @@ -444,7 +471,7 @@ const yomichan = (() => { } _onMessageGetUrl() { - return {url: window.location.href}; + return {url: this._getUrl()}; } _onMessageOptionsUpdated({source}) { diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index a2b2b139..3f3a155e 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -17,7 +17,7 @@ /* global * TemplateHandler - * apiGetDisplayTemplatesHtml + * api * jp */ @@ -29,7 +29,7 @@ class DisplayGenerator { } async prepare() { - const html = await apiGetDisplayTemplatesHtml(); + const html = await api.getDisplayTemplatesHtml(); this._templateHandler = new TemplateHandler(html); } diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 2e59b4ff..1d699706 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -22,15 +22,7 @@ * DisplayGenerator * MediaLoader * WindowScroll - * apiAudioGetUri - * apiBroadcastTab - * apiDefinitionAdd - * apiDefinitionsAddable - * apiKanjiFind - * apiNoteView - * apiOptionsGet - * apiScreenshotGet - * apiTermsFind + * api * docRangeFromPoint * docSentenceExtract */ @@ -49,7 +41,7 @@ class Display { this.audioSystem = new AudioSystem({ audioUriBuilder: { getUri: async (definition, source, details) => { - return await apiAudioGetUri(definition, source, details); + return await api.audioGetUri(definition, source, details); } }, useCache: true @@ -212,7 +204,7 @@ class Display { url: this.context.get('url') }; - const definitions = await apiKanjiFind(link.textContent, this.getOptionsContext()); + const definitions = await api.kanjiFind(link.textContent, this.getOptionsContext()); this.setContent('kanji', {definitions, context}); } catch (error) { this.onError(error); @@ -244,7 +236,9 @@ class Display { const {textSource, definitions} = termLookupResults; const scannedElement = e.target; - const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); + const sentenceExtent = this.options.anki.sentenceExt; + const layoutAwareScan = this.options.scanning.layoutAwareScan; + const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); this.context.update({ index: this.entryIndexFind(scannedElement), @@ -281,21 +275,22 @@ class Display { try { e.preventDefault(); - const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options.scanning.deepDomScan); + const {length: scanLength, deepDomScan: deepScan, layoutAwareScan} = this.options.scanning; + const textSource = docRangeFromPoint(e.clientX, e.clientY, deepScan); if (textSource === null) { return false; } let definitions, length; try { - textSource.setEndOffset(this.options.scanning.length); + textSource.setEndOffset(scanLength, layoutAwareScan); - ({definitions, length} = await apiTermsFind(textSource.text(), {}, this.getOptionsContext())); + ({definitions, length} = await api.termsFind(textSource.text(), {}, this.getOptionsContext())); if (definitions.length === 0) { return false; } - textSource.setEndOffset(length); + textSource.setEndOffset(length, layoutAwareScan); } finally { textSource.cleanup(); } @@ -334,7 +329,7 @@ class Display { onNoteView(e) { e.preventDefault(); const link = e.currentTarget; - apiNoteView(link.dataset.noteId); + api.noteView(link.dataset.noteId); } onKeyDown(e) { @@ -379,7 +374,7 @@ class Display { } async updateOptions() { - this.options = await apiOptionsGet(this.getOptionsContext()); + this.options = await api.optionsGet(this.getOptionsContext()); this.updateDocumentOptions(this.options); this.updateTheme(this.options.general.popupTheme); this.setCustomCss(this.options.general.customPopupCss); @@ -746,7 +741,7 @@ class Display { noteTryView() { const button = this.viewerButtonFind(this.index); if (button !== null && !button.classList.contains('disabled')) { - apiNoteView(button.dataset.noteId); + api.noteView(button.dataset.noteId); } } @@ -763,7 +758,7 @@ class Display { } const context = await this._getNoteContext(); - const noteId = await apiDefinitionAdd(definition, mode, context, details, this.getOptionsContext()); + const noteId = await api.definitionAdd(definition, mode, context, details, this.getOptionsContext()); if (noteId) { const index = this.definitions.indexOf(definition); const adderButton = this.adderButtonFind(index, mode); @@ -815,9 +810,10 @@ class Display { this._stopPlayingAudio(); + const volume = Math.max(0.0, Math.min(1.0, this.options.audio.volume / 100.0)); this.audioPlaying = audio; audio.currentTime = 0; - audio.volume = this.options.audio.volume / 100.0; + audio.volume = Number.isFinite(volume) ? volume : 1.0; const playPromise = audio.play(); if (typeof playPromise !== 'undefined') { try { @@ -857,7 +853,7 @@ class Display { await promiseTimeout(1); // Wait for popup to be hidden. const {format, quality} = this.options.anki.screenshot; - const dataUrl = await apiScreenshotGet({format, quality}); + const dataUrl = await api.screenshotGet({format, quality}); if (!dataUrl || dataUrl.error) { return; } return {dataUrl, format}; @@ -871,7 +867,7 @@ class Display { } setPopupVisibleOverride(visible) { - return apiBroadcastTab('popupSetVisibleOverride', {visible}); + return api.broadcastTab('popupSetVisibleOverride', {visible}); } setSpinnerVisible(visible) { @@ -933,7 +929,7 @@ class Display { async getDefinitionsAddable(definitions, modes) { try { const context = await this._getNoteContext(); - return await apiDefinitionsAddable(definitions, modes, context, this.getOptionsContext()); + return await api.definitionsAddable(definitions, modes, context, this.getOptionsContext()); } catch (e) { return []; } diff --git a/ext/mixed/js/dom-data-binder.js b/ext/mixed/js/dom-data-binder.js new file mode 100644 index 00000000..d46e8087 --- /dev/null +++ b/ext/mixed/js/dom-data-binder.js @@ -0,0 +1,349 @@ +/* + * 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 + * TaskAccumulator + */ + +class DOMDataBinder { + constructor({selector, ignoreSelectors=[], createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) { + this._selector = selector; + this._ignoreSelectors = ignoreSelectors; + this._createElementMetadata = createElementMetadata; + this._compareElementMetadata = compareElementMetadata; + this._getValues = getValues; + this._setValues = setValues; + this._onError = onError; + this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this)); + this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this)); + this._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + this._observingElement = null; + this._elementMap = new Map(); // Map([element => observer]...) + this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)) + } + + observe(element) { + if (this._isObserving) { return; } + + this._observingElement = element; + this._mutationObserver.observe(element, { + attributes: true, + attributeOldValue: true, + childList: true, + subtree: true + }); + this._onMutation([{ + type: 'childList', + target: element.parentNode, + addedNodes: [element], + removedNodes: [] + }]); + } + + disconnect() { + if (!this._isObserving) { return; } + + this._mutationObserver.disconnect(); + this._observingElement = null; + + for (const observer of this._elementMap.values()) { + this._removeObserver(observer); + } + } + + async refresh() { + await this._updateTasks.enqueue(null, {all: true}); + } + + // Private + + _onMutation(mutationList) { + for (const mutation of mutationList) { + switch (mutation.type) { + case 'childList': + this._onChildListMutation(mutation); + break; + case 'attributes': + this._onAttributeMutation(mutation); + break; + } + } + } + + _onChildListMutation({addedNodes, removedNodes, target}) { + const selector = this._selector; + const ELEMENT_NODE = Node.ELEMENT_NODE; + + for (const node of removedNodes) { + const observers = this._elementAncestorMap.get(node); + if (typeof observers === 'undefined') { continue; } + for (const observer of observers) { + this._removeObserver(observer); + } + } + + for (const node of addedNodes) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { + this._createObserver(node); + } + for (const childNode of node.querySelectorAll(selector)) { + this._createObserver(childNode); + } + } + + if (addedNodes.length !== 0 || addedNodes.length !== 0) { + const observer = this._elementMap.get(target); + if (typeof observer !== 'undefined' && observer.hasValue) { + this._setElementValue(observer.element, observer.value); + } + } + } + + _onAttributeMutation({target}) { + const selector = this._selector; + const observers = this._elementAncestorMap.get(target); + if (typeof observers !== 'undefined') { + for (const observer of observers) { + const element = observer.element; + if ( + !element.matches(selector) || + this._shouldIgnoreElement(element) || + this._isObserverStale(observer) + ) { + this._removeObserver(observer); + } + } + } + + if (target.matches(selector)) { + this._createObserver(target); + } + } + + async _onBulkUpdate(tasks) { + let all = false; + const targets = []; + for (const [observer, task] of tasks) { + if (observer === null) { + if (task.data.all) { + all = true; + break; + } + } else { + targets.push([observer, task]); + } + } + if (all) { + targets.length = 0; + for (const observer of this._elementMap.values()) { + targets.push([observer, null]); + } + } + + const args = targets.map(([observer]) => ({ + element: observer.element, + metadata: observer.metadata + })); + const responses = await this._getValues(args); + this._applyValues(targets, responses, true); + } + + async _onBulkAssign(tasks) { + const targets = tasks; + const args = targets.map(([observer, task]) => ({ + element: observer.element, + metadata: observer.metadata, + value: task.data.value + })); + const responses = await this._setValues(args); + this._applyValues(targets, responses, false); + } + + _onElementChange(observer) { + const value = this._getElementValue(observer.element); + observer.value = value; + observer.hasValue = true; + this._assignTasks.enqueue(observer, {value}); + } + + _applyValues(targets, response, ignoreStale) { + if (!Array.isArray(response)) { return; } + + for (let i = 0, ii = targets.length; i < ii; ++i) { + const [observer, task] = targets[i]; + const {error, result} = response[i]; + const stale = (task !== null && task.stale); + + if (error) { + if (typeof this._onError === 'function') { + this._onError(error, stale, observer.element, observer.metadata); + } + continue; + } + + if (stale && !ignoreStale) { continue; } + + observer.value = result; + observer.hasValue = true; + this._setElementValue(observer.element, result); + } + } + + _createObserver(element) { + if (this._elementMap.has(element) || this._shouldIgnoreElement(element)) { return; } + + const metadata = this._createElementMetadata(element); + const nodeName = element.nodeName.toUpperCase(); + const ancestors = this._getAncestors(element); + const observer = { + element, + ancestors, + type: (nodeName === 'INPUT' ? element.type : null), + value: null, + hasValue: false, + onChange: null, + metadata + }; + observer.onChange = this._onElementChange.bind(this, observer); + this._elementMap.set(element, observer); + + element.addEventListener('change', observer.onChange, false); + + for (const ancestor of ancestors) { + let observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { + observers = new Set(); + this._elementAncestorMap.set(ancestor, observers); + } + observers.add(observer); + } + + this._updateTasks.enqueue(observer); + } + + _removeObserver(observer) { + const {element, ancestors} = observer; + + element.removeEventListener('change', observer.onChange, false); + observer.onChange = null; + + this._elementMap.delete(element); + + for (const ancestor of ancestors) { + const observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { continue; } + + observers.delete(observer); + if (observers.size === 0) { + this._elementAncestorMap.delete(ancestor); + } + } + } + + _isObserverStale(observer) { + const {element, type, metadata} = observer; + const nodeName = element.nodeName.toUpperCase(); + return !( + type === (nodeName === 'INPUT' ? element.type : null) && + this._compareElementMetadata(metadata, this._createElementMetadata(element)) + ); + } + + _shouldIgnoreElement(element) { + for (const selector of this._ignoreSelectors) { + if (element.matches(selector)) { + return true; + } + } + return false; + } + + _getAncestors(node) { + const root = this._observingElement; + const results = []; + while (true) { + results.push(node); + if (node === root) { break; } + node = node.parentNode; + if (node === null) { break; } + } + return results; + } + + _setElementValue(element, value) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + element.checked = value; + break; + case 'text': + case 'number': + element.value = value; + break; + } + break; + case 'TEXTAREA': + case 'SELECT': + element.value = value; + break; + } + } + + _getElementValue(element) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + return !!element.checked; + case 'text': + return `${element.value}`; + case 'number': + return this._getInputNumberValue(element); + } + break; + case 'TEXTAREA': + case 'SELECT': + return element.value; + } + return null; + } + + _getInputNumberValue(element) { + let value = parseFloat(element.value); + if (!Number.isFinite(value)) { return 0; } + + let {min, max, step} = element; + min = this._stringValueToNumberOrNull(min); + max = this._stringValueToNumberOrNull(max); + step = this._stringValueToNumberOrNull(step); + if (typeof min === 'number') { value = Math.max(value, min); } + if (typeof max === 'number') { value = Math.min(value, max); } + if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; } + return value; + } + + _stringValueToNumberOrNull(value) { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + + const number = parseFloat(value); + return !Number.isNaN(number) ? number : null; + } +} diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js index 0e8f4462..59fea9f6 100644 --- a/ext/mixed/js/dom.js +++ b/ext/mixed/js/dom.js @@ -77,6 +77,24 @@ class DOM { return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); } + static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) { + const target = document; + const options = false; + const fullscreenEventNames = [ + 'fullscreenchange', + 'MSFullscreenChange', + 'mozfullscreenchange', + 'webkitfullscreenchange' + ]; + for (const eventName of fullscreenEventNames) { + if (eventListenerCollection === null) { + target.addEventListener(eventName, onFullscreenChanged, options); + } else { + eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options); + } + } + } + static getFullscreenElement() { return ( document.fullscreenElement || @@ -86,4 +104,42 @@ class DOM { null ); } + + static getNodesInRange(range) { + const end = range.endContainer; + const nodes = []; + for (let node = range.startContainer; node !== null; node = DOM.getNextNode(node)) { + nodes.push(node); + if (node === end) { break; } + } + return nodes; + } + + static getNextNode(node) { + let next = node.firstChild; + if (next === null) { + while (true) { + next = node.nextSibling; + if (next !== null) { break; } + + next = node.parentNode; + if (next === null) { break; } + + node = next; + } + } + return next; + } + + static anyNodeMatchesSelector(nodes, selector) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (let node of nodes) { + for (; node !== null; node = node.parentNode) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { return true; } + break; + } + } + return false; + } } diff --git a/ext/mixed/js/dynamic-loader.js b/ext/mixed/js/dynamic-loader.js index ce946109..981d1ee5 100644 --- a/ext/mixed/js/dynamic-loader.js +++ b/ext/mixed/js/dynamic-loader.js @@ -16,19 +16,41 @@ */ /* global - * apiInjectStylesheet + * api */ const dynamicLoader = (() => { const injectedStylesheets = new Map(); + const injectedStylesheetsWithParent = new WeakMap(); - async function loadStyle(id, type, value, useWebExtensionApi=false) { + function getInjectedStylesheet(id, parentNode) { + if (parentNode === null) { + return injectedStylesheets.get(id); + } + const map = injectedStylesheetsWithParent.get(parentNode); + return typeof map !== 'undefined' ? map.get(id) : void 0; + } + + function setInjectedStylesheet(id, parentNode, value) { + if (parentNode === null) { + injectedStylesheets.set(id, value); + return; + } + let map = injectedStylesheetsWithParent.get(parentNode); + if (typeof map === 'undefined') { + map = new Map(); + injectedStylesheetsWithParent.set(parentNode, map); + } + map.set(id, value); + } + + async function loadStyle(id, type, value, useWebExtensionApi=false, parentNode=null) { 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); + let styleNode = getInjectedStylesheet(id, parentNode); if (typeof styleNode !== 'undefined') { if (styleNode === null) { // Previously injected via WebExtension API @@ -38,21 +60,30 @@ const dynamicLoader = (() => { styleNode = null; } + if (type === 'file-content') { + value = await api.getStylesheetContent(value); + type = 'code'; + useWebExtensionApi = false; + } + if (useWebExtensionApi) { // Inject via WebExtension API if (styleNode !== null && styleNode.parentNode !== null) { styleNode.parentNode.removeChild(styleNode); } - injectedStylesheets.set(id, null); - await apiInjectStylesheet(type, value); + setInjectedStylesheet(id, parentNode, null); + await api.injectStylesheet(type, value); return null; } // Create node in document - const parentNode = document.head; - if (parentNode === null) { - throw new Error('No parent node'); + let parentNode2 = parentNode; + if (parentNode2 === null) { + parentNode2 = document.head; + if (parentNode2 === null) { + throw new Error('No parent node'); + } } // Create or reuse node @@ -74,12 +105,12 @@ const dynamicLoader = (() => { } // Update parent - if (styleNode.parentNode !== parentNode) { - parentNode.appendChild(styleNode); + if (styleNode.parentNode !== parentNode2) { + parentNode2.appendChild(styleNode); } // Add to map - injectedStylesheets.set(id, styleNode); + setInjectedStylesheet(id, parentNode, styleNode); return styleNode; } diff --git a/ext/mixed/js/environment.js b/ext/mixed/js/environment.js index e5bc20a7..5bd84010 100644 --- a/ext/mixed/js/environment.js +++ b/ext/mixed/js/environment.js @@ -32,17 +32,40 @@ class Environment { async _loadEnvironmentInfo() { const browser = await this._getBrowser(); - const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); - const modifierInfo = this._getModifierInfo(browser, platform.os); + const os = await this._getOperatingSystem(); + const modifierInfo = this._getModifierInfo(browser, os); return { browser, - platform: { - os: platform.os - }, + platform: {os}, modifiers: modifierInfo }; } + async _getOperatingSystem() { + try { + const {os} = await this._getPlatformInfo(); + if (typeof os === 'string') { + return os; + } + } catch (e) { + // NOP + } + return 'unknown'; + } + + _getPlatformInfo() { + return new Promise((resolve, reject) => { + chrome.runtime.getPlatformInfo((result) => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + } + async _getBrowser() { if (EXTENSION_IS_BROWSER_EDGE) { return 'edge'; @@ -96,8 +119,15 @@ class Environment { ['meta', 'Super'] ]; break; - default: - throw new Error(`Invalid OS: ${os}`); + default: // 'unknown', etc + separator = ' + '; + osKeys = [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Meta'] + ]; + break; } const isFirefox = (browser === 'firefox' || browser === 'firefox-mobile'); diff --git a/ext/mixed/js/media-loader.js b/ext/mixed/js/media-loader.js index 64ccd715..fc6e93d1 100644 --- a/ext/mixed/js/media-loader.js +++ b/ext/mixed/js/media-loader.js @@ -16,7 +16,7 @@ */ /* global - * apiGetMedia + * api */ class MediaLoader { @@ -84,7 +84,7 @@ class MediaLoader { async _getMediaData(path, dictionaryName, cachedData) { const token = this._token; - const data = (await apiGetMedia([{path, dictionaryName}]))[0]; + const data = (await api.getMedia([{path, dictionaryName}]))[0]; if (token === this._token && data !== null) { const contentArrayBuffer = this._base64ToArrayBuffer(data.content); const blob = new Blob([contentArrayBuffer], {type: data.mediaType}); diff --git a/ext/mixed/js/task-accumulator.js b/ext/mixed/js/task-accumulator.js new file mode 100644 index 00000000..5c6fe312 --- /dev/null +++ b/ext/mixed/js/task-accumulator.js @@ -0,0 +1,81 @@ +/* + * 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 TaskAccumulator { + constructor(runTasks) { + this._deferPromise = null; + this._activePromise = null; + this._tasks = []; + this._tasksActive = []; + this._uniqueTasks = new Map(); + this._uniqueTasksActive = new Map(); + this._runTasksBind = this._runTasks.bind(this); + this._tasksCompleteBind = this._tasksComplete.bind(this); + this._runTasks = runTasks; + } + + enqueue(key, data) { + if (this._deferPromise === null) { + const promise = this._activePromise !== null ? this._activePromise : Promise.resolve(); + this._deferPromise = promise.then(this._runTasksBind); + } + + const task = {data, stale: false}; + if (key !== null) { + const activeTaskInfo = this._uniqueTasksActive.get(key); + if (typeof activeTaskInfo !== 'undefined') { + activeTaskInfo.stale = true; + } + + this._uniqueTasks.set(key, task); + } else { + this._tasks.push(task); + } + + return this._deferPromise; + } + + _runTasks() { + this._deferPromise = null; + + // Swap + [this._tasks, this._tasksActive] = [this._tasksActive, this._tasks]; + [this._uniqueTasks, this._uniqueTasksActive] = [this._uniqueTasksActive, this._uniqueTasks]; + + const promise = this._runTasksAsync(); + this._activePromise = promise.then(this._tasksCompleteBind); + return this._activePromise; + } + + async _runTasksAsync() { + try { + const allTasks = [ + ...this._tasksActive.map((taskInfo) => [null, taskInfo]), + ...this._uniqueTasksActive.entries() + ]; + await this._runTasks(allTasks); + } catch (e) { + yomichan.logError(e); + } + } + + _tasksComplete() { + this._tasksActive.length = 0; + this._uniqueTasksActive.clear(); + this._activePromise = null; + } +} diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index b8688b08..7c705fc8 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -17,7 +17,6 @@ /* global * DOM - * TextSourceRange * docRangeFromPoint */ @@ -29,6 +28,7 @@ class TextScanner extends EventDispatcher { this._ignorePoint = ignorePoint; this._search = search; + this._isPrepared = false; this._ignoreNodes = null; this._causeCurrent = null; @@ -70,10 +70,15 @@ class TextScanner extends EventDispatcher { return this._causeCurrent; } + prepare() { + this._isPrepared = true; + this.setEnabled(this._enabled); + } + setEnabled(enabled) { this._eventListeners.removeAllEventListeners(); this._enabled = enabled; - if (this._enabled) { + if (this._enabled && this._isPrepared) { this._hookEvents(); } else { this.clearSelection(true); @@ -119,20 +124,20 @@ class TextScanner extends EventDispatcher { } } - getTextSourceContent(textSource, length) { + getTextSourceContent(textSource, length, layoutAwareScan) { const clonedTextSource = textSource.clone(); - clonedTextSource.setEndOffset(length); + clonedTextSource.setEndOffset(length, layoutAwareScan); 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)) { + const nodes = DOM.getNodesInRange(clonedTextSource.range); + if (!DOM.anyNodeMatchesSelector(nodes, this._ignoreNodes)) { break; } --length; - clonedTextSource.setEndOffset(length); + clonedTextSource.setEndOffset(length, layoutAwareScan); } } diff --git a/resources/icons.svg b/resources/icons.svg index f096947b..86c25825 100644 --- a/resources/icons.svg +++ b/resources/icons.svg @@ -10,16 +10,16 @@ xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="16" - height="16" - viewBox="0 0 16 16" - version="1.1" - id="svg8" - inkscape:version="0.92.4 (5da689c313, 2019-01-14)" - sodipodi:docname="icons.svg" - inkscape:export-xdpi="192" + inkscape:export-filename="../ext/mixed/img/icon32.png" inkscape:export-ydpi="192" - inkscape:export-filename="../ext/mixed/img/icon32.png"> + inkscape:export-xdpi="192" + sodipodi:docname="icons.svg" + inkscape:version="0.92.4 (5da689c313, 2019-01-14)" + id="svg8" + version="1.1" + viewBox="0 0 16 16" + height="16" + width="16"> <sodipodi:namedview id="base" pagecolor="#ffffff" @@ -27,11 +27,11 @@ borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" - inkscape:zoom="31.678384" - inkscape:cx="6.7837201" - inkscape:cy="7.0569373" + inkscape:zoom="22.627417" + inkscape:cx="12.059712" + inkscape:cy="6.3977551" inkscape:document-units="px" - inkscape:current-layer="layer24" + inkscape:current-layer="layer37" showgrid="true" units="px" inkscape:snap-center="true" @@ -40,14 +40,22 @@ inkscape:window-x="-8" inkscape:window-y="-8" inkscape:window-maximized="1" - viewbox-height="16"> + viewbox-height="16" + showguides="true" + inkscape:guide-bbox="true" + inkscape:snap-object-midpoints="true" + inkscape:snap-smooth-nodes="true" + inkscape:snap-intersection-paths="true" + inkscape:object-paths="true" + inkscape:snap-others="true" + inkscape:snap-nodes="true"> <inkscape:grid - dotted="false" - spacingy="0.25" - spacingx="0.25" - empspacing="8" + type="xygrid" id="grid815" - type="xygrid" /> + empspacing="8" + spacingx="0.25" + spacingy="0.25" + dotted="false" /> </sodipodi:namedview> <defs id="defs2"> @@ -490,6 +498,16 @@ id="linearGradient5227" xlink:href="#linearGradient5225" inkscape:collect="always" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4867" + id="linearGradient2534" + gradientUnits="userSpaceOnUse" + gradientTransform="scale(3.7795275,3.7795165)" + x1="0.5291667" + y1="2.6458333" + x2="0.5291667" + y2="1.4552083" /> </defs> <metadata id="metadata5"> @@ -499,7 +517,7 @@ <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> @@ -670,7 +688,7 @@ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0 U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAH0SURBVDjLxdPPS9tgGAfwgH/ATmPD0w5j MFa3IXOMFImsOKnbmCUTacW1WZM2Mf1ho6OBrohkIdJfWm9aLKhM6GF4Lz3No/+AMC/PYQXBXL1+ 95oxh1jGhsgOX/LywvN5n/fN+3IAuKuEuzagVFoO27b1/Z+BcrnUx4otx7FPLWsJvYpIM2SS9H4P qNWqfK1W8VKplHlW/G1zs4G9vS9YXPx4CaDkXOFES4Om4gceUK2WsbZWR72+gtXVFezsbKHVamF7 ewtm/sMFgBJZhd6pvm4kDndaAo2KOmt5Gfv7X9HpdNBut9FsNmFZFgPrMHKZc4DkjHyi6KC3MZNe hTOuGAH5Xx5ybK/Y3f0Mx3Fg2zaKxSIMw2DjT0inNQ84nogcUUQJHIfZquNT3hzx46DBALizg2o0 1qEoCqLRKERRRDAYhKYlWRK/AJdCMwH2BY28+Qk8fg667wdXKJjY2FiHaeaRzWYQCk1AEASGzSCZ jP/ewtik5r6eBD0dM+nRSMb1j4LuPDnkFhZymJ/PsmLdazmV0jxEkqKsK+niIQ69mKUBwdd9OAx3 SADdHtC53FyK12dVXlVlPpF4zytK7OgMyucNyHLs8m+8+2zJHRwG3fId9LxIbNU+OR6zWU57AR5y 84FKN+71//EqM2iapfv/HtPf5gcdtKR8VW88PgAAAABJRU5ErkJggg== " id="image4790" x="0" - y="-1.6125985e-007" /> + y="-1.6125985e-07" /> </g> <g style="display:inline" @@ -833,7 +851,7 @@ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0 U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAI6SURBVDjLpVNNaFNBEP5e8hJiSjVVTCom sdYUMbRVUhSVgFawgqgo6FFBaMEeehQ8WAQFj57FCh4KVixCERXipQhCa6kaEz00uSQIghibNn++ t7tv4+6mTU2bi3Rh+WZn95v5ZndWq1ar2MzQ1zuGHs85xwaPEIF9qz5uWbBW5vjIiY/Sd+n+qz5G KbT1CgRxnwCPmPPBHW5wLolcBTEJxfT7+RtccI5Fwg9RtdYU3Jwddgp4DVwfrXJrBpoNt87trwfm nCP2KYvU9z13ZObTB/04e7izoYRvFrP8qwspV45kMqlsxhj6u7uxd7u+q7V1KwK+NsTj8VoJIvsX n7O9Vx7K5rMgJkVpqQzTICjmSwrl+unQJDKZDMLhMLxerwqqC/IHr8PX29HSCcYZ/C1BhRVigHKK P1SgxTAx8QwyWaFQgGmaSl0qlYIuZFOmMRCLKCITh6lA0zIFkcJkZs1HmCL9e+mhUAj6g+ij6HDs 2udypXLIZd+C7M8sfuVzDdJlSYyyBrK00+n02jNefX55gRgkyAo9I05ycmx5aRlTty/AMAxVKyEE uVwOiUQCkUgEgUBA+eqvIMg9IuNLe/H4V2arEeRwuVz1jG63Gx6PR01d1+FwODY20vm7U0ftNm1m 8fciKCWidrqCNfti9IAKNv5mVvjpxlbWgB9yo2P3zqa9/+LdnLqPMwP9zf+ClC4zZgrFpgrafV7V WLG300qB9j+/sevKvSflcumUbOVtnraF9OTogLbZ7/wXRdt3lZxkvhIAAAAASUVORK5CYII= " id="image5115" x="0" - y="-1.6125985e-007" /> + y="-1.6125985e-07" /> </g> <g inkscape:groupmode="layer" @@ -893,7 +911,8 @@ <g inkscape:groupmode="layer" id="layer22" - inkscape:label="Yomichan"> + inkscape:label="Yomichan" + style="display:none"> <g inkscape:groupmode="layer" id="layer23" @@ -911,11 +930,274 @@ <g inkscape:groupmode="layer" id="layer24" - inkscape:label="Characters"> + inkscape:label="Characters" + style="display:inline"> <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" /> + d="M 2,2 V 4 H 5 V 7 H 2 v 2 h 3 v 3 H 2 v 2 H 7 V 2 Z m 7,0 v 2 h 5 V 2 Z m 0,5 v 2 h 5 V 7 Z m 0,5 v 2 h 5 v -2 z" + id="path3859" + inkscape:connector-curvature="0" /> </g> </g> + <g + inkscape:groupmode="layer" + id="layer25" + inkscape:label="Profile" + style="display:none" + sodipodi:insensitive="true"> + <path + id="path4006" + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 5.5039062,9.3261719 C 4.6524247,9.5545753 3.8719288,9.8939745 3.203125,10.339844 1.9220053,11.193923 1.0000001,12.4901 1,14 c 5.52e-5,0.552262 0.4477381,0.999945 1,1 h 6 6 c 0.552262,-5.5e-5 0.999945,-0.447738 1,-1 0,-1.5099 -0.922005,-2.806076 -2.203125,-3.660156 C 12.127121,9.8933412 11.34509,9.5545666 10.492188,9.3261719 A 5,5 0 0 1 8,10 5,5 0 0 1 5.5039062,9.3261719 Z M 12,5 A 4,4 0 0 1 8,9 4,4 0 0 1 4,5 4,4 0 0 1 8,1 4,4 0 0 1 12,5 Z" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer28" + inkscape:label="Cog" + style="display:none" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="M 7,0.5 C 6.8619288,0.5 6.7383484,0.61066107 6.7246094,0.74804688 L 6.5683594,2.3027344 C 6.546378,2.5225521 6.3561659,2.7490391 6.1484375,2.8242188 5.9797973,2.8851018 5.814235,2.9541948 5.6523438,3.03125 5.4529686,3.1263955 5.1573253,3.1027578 4.9863281,2.9628906 L 3.7734375,1.9707031 C 3.666576,1.883272 3.5019278,1.8926038 3.4042969,1.9902344 L 1.9902344,3.4042969 C 1.8926044,3.5019279 1.8832732,3.666576 1.9707031,3.7734375 L 2.9609375,4.984375 C 3.1007811,5.1553924 3.1251835,5.4504423 3.03125,5.6503906 2.9554595,5.8129941 2.8876702,5.9792085 2.828125,6.1484375 2.7541632,6.3566024 2.528418,6.5464423 2.3085938,6.5683594 l -1.56054692,0.15625 C 0.61066106,6.7383484 0.4999999,6.861929 0.5,7 v 2 c 8e-7,0.13807 0.11066217,0.2616506 0.24804688,0.2753906 l 1.55468752,0.15625 c 0.2198177,0.02198 0.4463049,0.2121939 0.5214844,0.4199219 0.060883,0.1686405 0.129976,0.3342025 0.2070312,0.4960935 0.095145,0.199375 0.071508,0.495019 -0.068359,0.666016 l -0.9921875,1.21289 c -0.087432,0.106862 -0.0781,0.27151 0.019531,0.369141 l 1.4140625,1.414063 c 0.097631,0.09763 0.262279,0.106963 0.3691406,0.01953 L 4.984375,13.039062 c 0.1710173,-0.139844 0.4660673,-0.164253 0.6660156,-0.07031 0.1626035,0.07579 0.3288179,0.14358 0.4980469,0.203125 0.2081648,0.07396 0.3980048,0.299707 0.4199219,0.519531 l 0.15625,1.560547 C 6.7383484,15.389333 6.8619288,15.5 7,15.5 h 2 c 0.138071,0 0.2616516,-0.110661 0.2753906,-0.248047 l 0.15625,-1.554687 c 0.02198,-0.219818 0.2121939,-0.446306 0.4199219,-0.521485 0.1686405,-0.06088 0.3342025,-0.129976 0.4960935,-0.207031 0.199375,-0.09515 0.495019,-0.0715 0.666016,0.06836 l 1.21289,0.992188 c 0.106862,0.08743 0.271511,0.0781 0.369141,-0.01953 l 1.414063,-1.414063 c 0.09763,-0.09763 0.106963,-0.262281 0.01953,-0.369141 l -0.990235,-1.210937 c -0.139844,-0.171017 -0.164243,-0.466067 -0.07031,-0.666016 0.07579,-0.162603 0.14358,-0.328818 0.203125,-0.4980465 0.07396,-0.208165 0.299707,-0.3980049 0.519531,-0.4199219 l 1.560547,-0.15625 C 15.389342,9.2616486 15.5,9.13807 15.5,9 V 7 c 0,-0.138072 -0.110661,-0.2616522 -0.248047,-0.2753906 l -1.554687,-0.15625 C 13.477448,6.546378 13.25096,6.3561659 13.175781,6.1484375 13.114898,5.9797973 13.045805,5.814235 12.96875,5.6523438 12.8736,5.4529686 12.897239,5.1573253 13.037109,4.9863281 l 0.992188,-1.2128906 c 0.08743,-0.1068616 0.0781,-0.2715097 -0.01953,-0.3691406 L 12.595703,1.9902344 C 12.498072,1.8926028 12.333424,1.8832712 12.226562,1.9707031 L 11.015625,2.9609375 C 10.844608,3.100781 10.549558,3.1251835 10.349609,3.03125 10.187006,2.9554595 10.020791,2.8876702 9.8515625,2.828125 9.6433975,2.7541632 9.4535576,2.528418 9.4316406,2.3085938 L 9.2753906,0.74804688 C 9.2616516,0.61066107 9.138071,0.5 9,0.5 Z M 8,5.25 A 2.75,2.75 0 0 1 10.75,8 2.75,2.75 0 0 1 8,10.75 2.75,2.75 0 0 1 5.25,8 2.75,2.75 0 0 1 8,5.25 Z" + id="path2444" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer29" + inkscape:label="Palette" + style="display:none" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" + d="M 8,0.5 C 3.8578644,0.5 0.5,3.8578644 0.5,8 c 0,4.142136 3.3578644,7.5 7.5,7.5 2.520901,0 1.2501693,-2.499566 0.4785156,-3.638672 C 7.6694832,10.667044 8.7504731,9.9998091 9.8613281,10.496094 13.047125,11.919376 15.5,10.75 15.5,8 15.5,3.8578644 12.142136,0.5 8,0.5 Z m -2,2 C 6.6903559,2.5 7.25,3.0596441 7.25,3.75 7.25,4.4403559 6.6903559,5 6,5 5.3096441,5 4.75,4.4403559 4.75,3.75 4.75,3.0596441 5.3096441,2.5 6,2.5 Z m 4,0 c 0.690356,0 1.25,0.5596441 1.25,1.25 C 11.25,4.4403559 10.690356,5 10,5 9.3096441,5 8.75,4.4403559 8.75,3.75 8.75,3.0596441 9.3096441,2.5 10,2.5 Z m -6.75,3 C 3.9403559,5.5 4.5,6.0596441 4.5,6.75 4.5,7.4403559 3.9403559,8 3.25,8 2.5596441,8 2,7.4403559 2,6.75 2,6.0596441 2.5596441,5.5 3.25,5.5 Z m 9.5,0 C 13.440356,5.5 14,6.0596441 14,6.75 14,7.4403559 13.440356,8 12.75,8 12.059644,8 11.5,7.4403559 11.5,6.75 11.5,6.0596441 12.059644,5.5 12.75,5.5 Z" + id="path2454" + inkscape:connector-curvature="0" + sodipodi:nodetypes="sssssssssssssssssssssssssss" /> + </g> + <g + inkscape:groupmode="layer" + id="layer30" + inkscape:label="Popup" + style="display:none" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" + d="M 1.25,2 C 0.8345,2 0.5,2.3345 0.5,2.75 v 8.5 C 0.5,11.6655 0.8345,12 1.25,12 H 2.5 v 2.75 L 4.75,12 h 9.75 c 0.4155,0 0.75,-0.3345 0.75,-0.75 V 2.75 C 15.25,2.3345 14.9155,2 14.5,2 Z M 2,3.5 H 5.5 V 5.25 H 2 Z M 2,7 H 14 V 8 H 2 Z m 0,2 h 10 v 1 H 2 Z" + id="rect2474" + inkscape:connector-curvature="0" + sodipodi:nodetypes="sssscccsssssccccccccccccccc" /> + </g> + <g + inkscape:groupmode="layer" + id="g2511" + inkscape:label="Speaker" + style="display:none" + sodipodi:insensitive="true"> + <path + id="path2507" + style="display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 12.25,2.75 c 1.5,3 1.5,7.5 0,10.5 4.25,-1.75 4.25,-8.75 0,-10.5 v 0 m -2.5,2 c 0.75,1.5 0.75,5 0,6.5 3,-1.25 3,-5.25 0,-6.5 v 0 M 7.9921875,2.25 C 7.9353665,2.25205 7.8809424,2.273407 7.8378906,2.310547 L 4.4082031,5.25 H 0.75 C 0.61193453,5.2500138 0.5000138,5.3619345 0.5,5.5 V 7.9824219 8 c -4.3004e-4,0.00846 -4.3004e-4,0.016933 0,0.025391 V 10.5 c 1.38e-5,0.138065 0.11193453,0.249986 0.25,0.25 h 3.6582031 l 3.4296875,2.939453 C 7.9999103,13.827401 8.2492199,13.71279 8.25,13.5 V 2.5 C 8.2500544,2.3588255 8.1332935,2.2456028 7.9921875,2.25 Z" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer31" + inkscape:label="Scanning" + style="display:none" + sodipodi:insensitive="true"> + <path + id="rect3472" + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 0.5,1.5 V 3 H 1.25 C 1.6784729,3 2,3.3215271 2,3.75 v 8.5 C 2,12.678473 1.6784729,13 1.25,13 H 0.5 v 1.5 h 0.75 c 0.5762336,0 1.0999924,-0.227703 1.5,-0.589844 C 3.1500076,14.272297 3.6737664,14.5 4.25,14.5 H 5 V 13 H 4.25 C 3.8215271,13 3.5,12.678473 3.5,12.25 V 3.75 C 3.5,3.3215271 3.8215271,3 4.25,3 H 5 V 1.5 H 4.25 C 3.6737664,1.5 3.1500076,1.7277027 2.75,2.0898438 2.3499924,1.7277027 1.8262336,1.5 1.25,1.5 Z M 11.746094,4.25 C 11.609556,4.252147 11.499997,4.3634454 11.5,4.5 v 2 H 8 v 3 h 3.5 v 2 c -0.0017,0.224275 0.269974,0.337152 0.427734,0.177734 l 3.5,-3.4999996 c 0.0989,-0.097852 0.0989,-0.2576169 0,-0.3554688 l -3.5,-3.5 C 11.879504,4.2745245 11.813939,4.2484395 11.746094,4.25 Z M 5,6.5 h 1.5 v 3 H 5 Z" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer32" + inkscape:label="Text Parsing Old" + style="display:none" + sodipodi:insensitive="true"> + <path + id="rect2609" + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.5;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" + d="m 12.25,10 h 3.25 v 2 H 12.25 Z M 7.5,10 h 3.25 v 2 H 7.5 Z m 0,-4 h 8 v 2 h -8 z M 0.5,2 V 4.5 H 0.75 C 0.75,4.223 0.973,4 1.25,4 H 3.5 C 3.777,4 4,4.223 4,4.5 v 8.75 c 0,0.277 -0.223,0.5 -0.5,0.5 V 14 h 3 V 13.75 C 6.223,13.75 6,13.527 6,13.25 V 4.5 C 6,4.223 6.223,4 6.5,4 h 2.25 c 0.277,0 0.5,0.223 0.5,0.5 H 9.5 V 2 Z" + inkscape:connector-curvature="0" /> + </g> + <g + style="display:none" + inkscape:label="Text Parsing" + id="g1040" + inkscape:groupmode="layer" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.5;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" + d="M 0.5,2 V 4.5 H 0.75 C 0.75,4.223 0.973,4 1.25,4 H 3.5 C 3.777,4 4,4.223 4,4.5 v 8.75 c 0,0.277 -0.223,0.5 -0.5,0.5 V 14 h 3 V 13.75 C 6.223,13.75 6,13.527 6,13.25 V 4.5 C 6,4.223 6.223,4 6.5,4 h 2.25 c 0.277,0 0.5,0.223 0.5,0.5 H 9.5 V 2 Z m 7,3.5 v 1 h 8 v -1 z m 0,2 v 1 h 5 v -1 z m 6,0 v 1 h 2 v -1 z m -6,2 v 1 h 2 v -1 z m 3,0 v 1 h 3 v -1 z m 4,0 v 1 h 1 v -1 z m -7,2 v 1 h 1 v -1 z m 2,0 v 1 H 12 v -1 z" + id="path1038" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer35" + inkscape:label="Hiragana A" + style="display:none" + sodipodi:insensitive="true"> + <path + id="path2631" + style="fill:none;stroke:#000000;stroke-width:1.85917425;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="M 2.4206399,2.8860135 H 11.948908 M 8.9277499,14.041059 C 13.575686,13.808662 14.040479,11.252298 14.057673,9.826907 14.076998,8.2248992 12.431443,5.850066 8.8325891,5.7748221 4.0474174,5.6747749 0.48884165,10.133888 2.4251965,12.742763 4.1458553,15.061028 8.4629564,12.414282 9.8573371,6.3719653 M 5.6741949,1.0268392 C 5.4417981,7.766346 5.743615,9.3386806 6.1389885,12.646678" + inkscape:connector-curvature="0" /> + </g> + <g + style="display:none" + inkscape:label="Translation" + id="g1060" + inkscape:groupmode="layer" + sodipodi:insensitive="true"> + <path + inkscape:connector-curvature="0" + d="M 1.3587336,1.6591219 H 6.75 M 5.0405743,7.9708488 C 7.6704608,7.8393538 7.9334491,6.3929173 7.9431777,5.5864052 7.9541152,4.6799597 7.0230274,3.3362365 4.9867306,3.2936615 2.2791938,3.2370561 0.26568714,5.7601005 1.3613118,7.2362493 2.3348917,8.5479657 4.7775858,7.0503889 5.5665516,3.6315358 M 3.199654,0.6071669 C 3.0681597,4.4205025 3.238933,5.3101581 3.4626426,7.1818822" + style="fill:none;stroke:#000000;stroke-width:1.25;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path1058" /> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 9.5,5.5 -4,10 h 2 l 1.099609,-2.75 h 3.800782 L 13.5,15.5 h 2 l -4,-10 z m 1,2.5 1.099609,2.75 H 9.400391 Z" + id="path1008" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer36" + inkscape:label="Book" + style="display:none" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" + d="M 2,2.25 3.75,0.5 H 14 v 13 l -1,1 V 1.5 H 4.75 l -1.0459224,1 H 12 v 13 H 2 Z" + id="rect2635" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccccccccc" /> + </g> + <g + inkscape:groupmode="layer" + id="layer34" + inkscape:label="Note Card" + style="display:none" + sodipodi:insensitive="true"> + <path + inkscape:connector-curvature="0" + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 4,0.5 C 3.1776486,0.5 2.5,1.1776486 2.5,2 v 12 c 0,0.822351 0.6776486,1.5 1.5,1.5 h 8 c 0.822351,0 1.5,-0.677649 1.5,-1.5 V 2 C 13.5,1.1776486 12.822351,0.5 12,0.5 Z m 6.759766,1.875 c 0.197356,0.00499 0.357052,0.1621255 0.365234,0.359375 l 0.04883,1.125 0.810547,0.78125 c 0.19826,0.192095 0.128053,0.525322 -0.130861,0.6210938 L 10.798828,5.6542969 10.306641,6.6679688 C 10.185523,6.9158798 9.8471907,6.9525412 9.6757812,6.7363281 L 8.9765625,5.8554688 7.8613281,5.6992188 C 7.5879604,5.6614889 7.4475826,5.3514007 7.5996094,5.1210938 L 8.2226562,4.1816406 8.0253906,3.0742188 C 7.9760798,2.8015267 8.2293522,2.5713746 8.4960938,2.6464844 L 9.5800781,2.9492188 10.574219,2.4199219 C 10.631159,2.3891929 10.695077,2.3737179 10.759766,2.375 Z M 4,9 h 8 v 1 H 4 Z m 0,2 h 8 v 1 H 4 Z m 0,2 h 6 v 1 H 4 Z" + id="rect2676-6" /> + </g> + <g + inkscape:groupmode="layer" + id="layer39" + inkscape:label="Key Shortcuts" + style="display:none" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.46875;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" + d="M 7.9926758,1.4301758 A 0.46879688,0.46879688 0 0 0 7.53125,1.90625 v 3.75 H 1.4375 C 0.918125,5.65625 0.5,6.074375 0.5,6.59375 v 6.09375 c 0,0.519375 0.418125,0.9375 0.9375,0.9375 h 13.125 c 0.519375,0 0.9375,-0.418125 0.9375,-0.9375 V 6.59375 c 0,-0.519375 -0.418125,-0.9375 -0.9375,-0.9375 H 8.46875 v -3.75 A 0.46879688,0.46879688 0 0 0 7.9926758,1.4301758 Z M 1.7890625,7.0625 h 1.171875 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.171875 c 0,0.064922 -0.052266,0.1171875 -0.1171875,0.1171875 H 1.7890625 C 1.7241405,8.46875 1.671875,8.416484 1.671875,8.3515625 V 7.1796875 C 1.671875,7.1147655 1.724141,7.0625 1.7890625,7.0625 Z m 1.875,0 h 1.171875 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.171875 c 0,0.064922 -0.052266,0.1171875 -0.1171875,0.1171875 H 3.6640625 C 3.5991405,8.46875 3.546875,8.416484 3.546875,8.3515625 V 7.1796875 C 3.546875,7.1147655 3.599141,7.0625 3.6640625,7.0625 Z m 1.875,0 h 1.171875 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.171875 c 0,0.064922 -0.052266,0.1171875 -0.1171875,0.1171875 H 5.5390625 C 5.4741405,8.46875 5.421875,8.416484 5.421875,8.3515625 V 7.1796875 C 5.421875,7.1147655 5.474141,7.0625 5.5390625,7.0625 Z m 1.875,0 h 1.171875 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.171875 c 0,0.064922 -0.052266,0.1171875 -0.1171875,0.1171875 H 7.4140625 C 7.3491405,8.46875 7.296875,8.416484 7.296875,8.3515625 V 7.1796875 C 7.296875,7.1147655 7.349141,7.0625 7.4140625,7.0625 Z m 1.875,0 h 1.1718755 c 0.06492,0 0.117187,0.052266 0.117187,0.1171875 v 1.171875 c 0,0.064922 -0.05227,0.1171875 -0.117187,0.1171875 H 9.2890625 C 9.2241405,8.46875 9.171875,8.416484 9.171875,8.3515625 V 7.1796875 C 9.171875,7.1147655 9.224141,7.0625 9.2890625,7.0625 Z m 1.8750005,0 h 1.171875 c 0.06492,0 0.117187,0.052266 0.117187,0.1171875 v 1.171875 c 0,0.064922 -0.05227,0.1171875 -0.117187,0.1171875 h -1.171875 c -0.06492,0 -0.117188,-0.052266 -0.117188,-0.1171875 v -1.171875 c 0,-0.064922 0.05227,-0.1171875 0.117188,-0.1171875 z m 1.875,0 h 1.171875 c 0.06492,0 0.117187,0.052266 0.117187,0.1171875 v 1.171875 c 0,0.064922 -0.05227,0.1171875 -0.117187,0.1171875 h -1.171875 c -0.06492,0 -0.117188,-0.052266 -0.117188,-0.1171875 v -1.171875 c 0,-0.064922 0.05227,-0.1171875 0.117188,-0.1171875 z M 1.7890625,8.9375 h 2.109375 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.1718745 c 0,0.06492 -0.052266,0.117188 -0.1171875,0.117188 h -2.109375 c -0.064922,0 -0.1171875,-0.05227 -0.1171875,-0.117188 V 9.0546875 C 1.671875,8.9897655 1.724141,8.9375 1.7890625,8.9375 Z m 2.8125,0 h 1.171875 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.1718745 c 0,0.06492 -0.052266,0.117188 -0.1171875,0.117188 h -1.171875 c -0.064922,0 -0.1171875,-0.05227 -0.1171875,-0.117188 V 9.0546875 C 4.484375,8.9897655 4.536641,8.9375 4.6015625,8.9375 Z m 1.875,0 h 1.171875 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.1718745 c 0,0.06492 -0.052266,0.117188 -0.1171875,0.117188 h -1.171875 c -0.064922,0 -0.1171875,-0.05227 -0.1171875,-0.117188 V 9.0546875 C 6.359375,8.9897655 6.411641,8.9375 6.4765625,8.9375 Z m 1.875,0 h 1.171875 c 0.064922,0 0.1171875,0.052266 0.1171875,0.1171875 v 1.1718745 c 0,0.06492 -0.052266,0.117188 -0.1171875,0.117188 h -1.171875 c -0.064922,0 -0.1171875,-0.05227 -0.1171875,-0.117188 V 9.0546875 C 8.234375,8.9897655 8.286641,8.9375 8.3515625,8.9375 Z m 1.8750005,0 h 1.171875 c 0.06492,0 0.117187,0.052266 0.117187,0.1171875 v 1.1718745 c 0,0.06492 -0.05227,0.117188 -0.117187,0.117188 h -1.171875 c -0.06492,0 -0.117188,-0.05227 -0.117188,-0.117188 V 9.0546875 c 0,-0.064922 0.05227,-0.1171875 0.117188,-0.1171875 z m 1.875,0 h 2.109375 c 0.06492,0 0.117187,0.052266 0.117187,0.1171875 v 1.1718745 c 0,0.06492 -0.05227,0.117188 -0.117187,0.117188 h -2.109375 c -0.06492,0 -0.117188,-0.05227 -0.117188,-0.117188 V 9.0546875 c 0,-0.064922 0.05227,-0.1171875 0.117188,-0.1171875 z m -10.3125005,1.875 h 1.171875 c 0.064922,0 0.1171875,0.05227 0.1171875,0.117187 v 1.171875 c 0,0.06492 -0.052266,0.117188 -0.1171875,0.117188 h -1.171875 c -0.064922,0 -0.1171875,-0.05227 -0.1171875,-0.117188 v -1.171875 c 0,-0.06492 0.052266,-0.117187 0.1171875,-0.117187 z m 1.875,0 h 1.171875 c 0.064922,0 0.1171875,0.05227 0.1171875,0.117187 v 1.171875 c 0,0.06492 -0.052266,0.117188 -0.1171875,0.117188 h -1.171875 c -0.064922,0 -0.1171875,-0.05227 -0.1171875,-0.117188 v -1.171875 c 0,-0.06492 0.052266,-0.117187 0.1171875,-0.117187 z m 1.875,0 h 4.9218755 c 0.06492,0 0.117187,0.05227 0.117187,0.117187 v 1.171875 c 0,0.06492 -0.05227,0.117188 -0.117187,0.117188 H 5.5390625 c -0.064922,0 -0.1171875,-0.05227 -0.1171875,-0.117188 v -1.171875 c 0,-0.06492 0.052266,-0.117187 0.1171875,-0.117187 z m 5.6250005,0 h 1.171875 c 0.06492,0 0.117187,0.05227 0.117187,0.117187 v 1.171875 c 0,0.06492 -0.05227,0.117188 -0.117187,0.117188 h -1.171875 c -0.06492,0 -0.117188,-0.05227 -0.117188,-0.117188 v -1.171875 c 0,-0.06492 0.05227,-0.117187 0.117188,-0.117187 z m 1.875,0 h 1.171875 c 0.06492,0 0.117187,0.05227 0.117187,0.117187 v 1.171875 c 0,0.06492 -0.05227,0.117188 -0.117187,0.117188 h -1.171875 c -0.06492,0 -0.117188,-0.05227 -0.117188,-0.117188 v -1.171875 c 0,-0.06492 0.05227,-0.117187 0.117188,-0.117187 z" + id="rect2732" + inkscape:connector-curvature="0" + inkscape:transform-center-y="-0.47244079" /> + </g> + <g + inkscape:groupmode="layer" + id="layer41" + inkscape:label="Backup" + style="display:none" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="M 6.5,1.25 C 6.3619345,1.2500138 6.2500138,1.3619345 6.25,1.5 v 6 H 4 C 3.7757241,7.4983254 3.6628471,7.7699743 3.8222656,7.9277344 l 4,3.9999996 c 0.097852,0.0989 0.2576169,0.0989 0.3554688,0 L 12.177734,7.9277344 C 12.337152,7.7699745 12.224276,7.4983257 12,7.5 H 9.75 v -6 C 9.7499862,1.3619345 9.6380655,1.2500138 9.5,1.25 Z M 2,11 v 3.5 H 14 V 11 h -1 v 1.5 H 3 V 11 Z" + id="path2855" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer42" + inkscape:label="Question Mark" + style="display:none;opacity:1" + sodipodi:insensitive="true"> + <path + id="path3461" + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 8,1.5 C 5.7177448,1.5 3.75,3.1227773 3.75,5.25 3.7500138,5.3880655 3.8619345,5.4999862 4,5.5 H 5 C 5.1380655,5.4999862 5.2499862,5.3880655 5.25,5.25 5.25,4.0697301 6.4120974,3 8,3 c 1.5879026,0 2.75,1.0697301 2.75,2.25 0,0.7462071 -0.435511,1.4210143 -1.1835938,1.8476562 C 9.0376373,7.3992206 8.4849984,7.7883793 8.0410156,8.2988281 7.5952465,8.8113307 7.25,9.4758533 7.25,10.25 v 1 c 1.38e-5,0.138065 0.1119345,0.249986 0.25,0.25 h 1 c 0.1380655,-1.4e-5 0.2499862,-0.111935 0.25,-0.25 v -1 C 8.75,9.9175101 8.8906513,9.6110182 9.1757812,9.2832031 9.4585751,8.9580741 9.8779243,8.6448934 10.306641,8.4003906 11.446808,7.7501366 12.25,6.5913459 12.25,5.25 12.25,3.1227773 10.282255,1.5 8,1.5 Z M 7.5,13 c -0.1380655,1.4e-5 -0.2499862,0.111935 -0.25,0.25 v 1 c 1.38e-5,0.138065 0.1119345,0.249986 0.25,0.25 h 1 c 0.1380655,-1.4e-5 0.2499862,-0.111935 0.25,-0.25 v -1 C 8.7499862,13.111935 8.6380655,13.000014 8.5,13 Z" + inkscape:connector-curvature="0" + sodipodi:nodetypes="sccccsssssccccsscssccccccccc" /> + </g> + <g + inkscape:groupmode="layer" + id="layer27" + inkscape:label="Question Mark Thick" + style="display:none;opacity:1" + sodipodi:insensitive="true"> + <path + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 8,1 C 5.0936716,1 2.5,3.0025541 2.5,5.75 2.5000138,5.8880655 2.6119345,5.9999862 2.75,6 h 2 C 4.8880655,5.9999862 4.9999862,5.8880655 5,5.75 5,4.6492015 6.2050401,3.5 8,3.5 9.7949596,3.5 11,4.6492016 11,5.75 11,6.5266793 10.46535,7.2794725 9.46875,7.7070312 8.8190955,7.9857439 8.1466608,8.4121068 7.6289062,9 7.1111517,9.5878932 6.75,10.346519 6.75,11.25 6.7500138,11.388065 6.8619345,11.499986 7,11.5 h 2 c 0.1380655,-1.4e-5 0.2499862,-0.111935 0.25,-0.25 0,-0.178084 0.104282,-0.411561 0.3164062,-0.640625 C 9.7785306,10.380311 10.089342,10.161928 10.453125,10.005859 12.183816,9.2633635 13.5,7.6733051 13.5,5.75 13.5,3.0025539 10.906328,1 8,1 Z M 7,12.5 c -0.1380655,1.4e-5 -0.2499862,0.111935 -0.25,0.25 v 2 C 6.7500138,14.888065 6.8619345,14.999986 7,15 h 2 c 0.1380655,-1.4e-5 0.2499862,-0.111935 0.25,-0.25 v -2 C 9.2499862,12.611935 9.1380655,12.500014 9,12.5 Z" + id="rect1045" + inkscape:connector-curvature="0" /> + </g> + <g + style="display:none;opacity:1" + inkscape:label="Question Mark Circle" + id="g2018" + inkscape:groupmode="layer" + sodipodi:insensitive="true"> + <path + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" + d="M 8,1 A 7,7 0 0 0 1,8 7,7 0 0 0 8,15 7,7 0 0 0 15,8 7,7 0 0 0 8,1 Z m 0,1.546875 c 2.264114,0 4.285156,1.5608313 4.285156,3.7011719 0,1.4983107 -1.026741,2.7360275 -2.3749998,3.3144531 -0.2833974,0.1215823 -0.5242021,0.2922557 -0.6894531,0.470703 -0.1652507,0.178448 -0.2460937,0.359314 -0.2460937,0.498047 -1.07e-5,0.107557 -0.087755,0.195301 -0.1953125,0.195312 H 7.2207031 c -0.1075573,-10e-6 -0.1953018,-0.08776 -0.1953125,-0.195312 0,-0.7038383 0.2822011,-1.2939671 0.6855469,-1.7519531 C 8.1142834,8.321311 8.6384315,7.9886097 9.1445312,7.7714844 9.9209118,7.4384037 10.337891,6.8531027 10.337891,6.2480469 10.337891,5.3904928 9.3983258,4.4941406 8,4.4941406 c -1.3983261,0 -2.3378906,0.8963522 -2.3378906,1.7539063 C 5.6620987,6.3556041 5.5763071,6.4413956 5.46875,6.4414062 H 3.9101562 C 3.802599,6.4413956 3.7148544,6.3556039 3.7148438,6.2480469 3.7148437,4.1077065 5.7358855,2.546875 8,2.546875 Z m -0.7792969,8.958984 h 1.5585938 c 0.1075573,1.1e-5 0.1953018,0.08776 0.1953125,0.195313 v 1.55664 c -1.07e-5,0.107557 -0.087755,0.195302 -0.1953125,0.195313 H 7.2207031 c -0.1075573,-1.1e-5 -0.1953018,-0.08776 -0.1953125,-0.195313 v -1.55664 c 1.07e-5,-0.107557 0.087755,-0.195302 0.1953125,-0.195313 z" + id="path2023" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer26" + inkscape:label="Magnifying Glass" + style="display:none" + sodipodi:insensitive="true"> + <path + id="path995" + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 10.78125,9.71875 9.75,9.75 l -0.03125,1.03125 4.5,4.5 c 0.708333,0.708496 1.770833,-0.354004 1.0625,-1.0625 z M 5.75,0.5 C 2.8593885,0.5 0.5,2.8593885 0.5,5.75 0.5,8.6406115 2.8593885,11 5.75,11 8.6406115,11 11,8.6406115 11,5.75 11,2.8593885 8.6406115,0.5 5.75,0.5 Z m 0,1.5 C 7.8299513,2 9.5,3.6700487 9.5,5.75 9.5,7.8299513 7.8299513,9.5 5.75,9.5 3.6700487,9.5 2,7.8299513 2,5.75 2,3.6700487 3.6700487,2 5.75,2 Z" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer33" + inkscape:label="Left Chevron" + sodipodi:insensitive="true" + style="display:none"> + <path + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 9.9394531,15.060547 12.060547,12.939453 7.1210938,8.0000001 12.060547,3.0605471 9.9394531,0.93945312 2.878906,8.0000001 Z" + id="path1000" + inkscape:connector-curvature="0" + inkscape:transform-center-x="0.5302735" /> + </g> + <g + inkscape:label="Right Chevron" + id="g1015" + inkscape:groupmode="layer" + style="display:none" + sodipodi:insensitive="true"> + <path + inkscape:connector-curvature="0" + id="path1013" + d="M 6.0605469,0.93945312 3.9394531,3.0605469 8.8789062,8 3.9394531,12.939453 6.0605469,15.060547 13.121094,8 Z" + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + inkscape:transform-center-x="-0.53027355" /> + </g> + <g + inkscape:groupmode="layer" + id="layer37" + inkscape:label="Key"> + <path + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="M 8 1.5 C 6.213958 1.5 4.75 2.963958 4.75 4.75 L 4.75 7.5 L 4 7.5 C 3.4477381 7.5000552 3.0000552 7.9477381 3 8.5 L 3 13.5 C 3.0000552 14.052262 3.4477381 14.499945 4 14.5 L 12 14.5 C 12.552262 14.499945 12.999945 14.052262 13 13.5 L 13 8.5 C 12.999945 7.9477381 12.552262 7.5000552 12 7.5 L 11.25 7.5 L 11.25 4.75 C 11.25 2.963958 9.786042 1.5 8 1.5 z M 8 3 C 8.9753818 3 9.75 3.7746182 9.75 4.75 L 9.75 7.5 L 6.25 7.5 L 6.25 4.75 C 6.25 3.7746182 7.0246182 3 8 3 z " + id="rect1210" /> + </g> </svg> diff --git a/test/test-database.js b/test/test-database.js index e8a4a343..03b2bd3b 100644 --- a/test/test-database.js +++ b/test/test-database.js @@ -38,60 +38,6 @@ const chrome = { } }; -class XMLHttpRequest { - constructor() { - this._eventCallbacks = new Map(); - this._url = ''; - this._responseText = null; - } - - overrideMimeType() { - // NOP - } - - addEventListener(eventName, callback) { - let callbacks = this._eventCallbacks.get(eventName); - if (typeof callbacks === 'undefined') { - callbacks = []; - this._eventCallbacks.set(eventName, callbacks); - } - callbacks.push(callback); - } - - open(action, url2) { - this._url = url2; - } - - send() { - const filePath = url.fileURLToPath(this._url); - Promise.resolve() - .then(() => { - let source; - try { - source = fs.readFileSync(filePath, {encoding: 'utf8'}); - } catch (e) { - this._trigger('error'); - return; - } - this._responseText = source; - this._trigger('load'); - }); - } - - get responseText() { - return this._responseText; - } - - _trigger(eventName, ...args) { - const callbacks = this._eventCallbacks.get(eventName); - if (typeof callbacks === 'undefined') { return; } - - for (let i = 0, ii = callbacks.length; i < ii; ++i) { - callbacks[i](...args); - } - } -} - class Image { constructor() { this._src = ''; @@ -138,11 +84,21 @@ class Image { } } +async function fetch(url2) { + const filePath = url.fileURLToPath(url2); + await Promise.resolve(); + const content = fs.readFileSync(filePath, {encoding: null}); + return { + text: async () => Promise.resolve(content.toString('utf8')), + json: async () => Promise.resolve(JSON.parse(content.toString('utf8'))) + }; +} + const vm = new VM({ chrome, Image, - XMLHttpRequest, + fetch, indexedDB: global.indexedDB, IDBKeyRange: global.IDBKeyRange, JSZip: yomichanTest.JSZip, @@ -159,6 +115,7 @@ vm.execute([ 'bg/js/media-utility.js', 'bg/js/request.js', 'bg/js/dictionary-importer.js', + 'bg/js/generic-database.js', 'bg/js/database.js' ]); const DictionaryImporter = vm.get('DictionaryImporter'); @@ -286,8 +243,8 @@ async function testDatabase1() { true ); vm.assert.deepStrictEqual(counts, { - counts: [{kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14}], - total: {kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14} + counts: [{kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14, media: 1}], + total: {kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14, media: 1} }); // Test find* functions @@ -313,7 +270,7 @@ async function testDatabaseEmpty1(database) { const counts = await database.getDictionaryCounts([], true); vm.assert.deepStrictEqual(counts, { counts: [], - total: {kanji: 0, kanjiMeta: 0, terms: 0, termMeta: 0, tagMeta: 0} + total: {kanji: 0, kanjiMeta: 0, terms: 0, termMeta: 0, tagMeta: 0, media: 0} }); } @@ -907,8 +864,7 @@ async function testDatabase2() { const database = new Database(); // 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, {rate: 1000}, () => {})); 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)); diff --git a/test/test-document.js b/test/test-document.js index 0d9026db..ba7acc49 100644 --- a/test/test-document.js +++ b/test/test-document.js @@ -94,10 +94,12 @@ async function testDocument1() { const vm = new VM({document, window, Range, Node}); vm.execute([ 'mixed/js/dom.js', + 'fg/js/dom-text-scanner.js', 'fg/js/source.js', 'fg/js/document.js' ]); - const [TextSourceRange, TextSourceElement, docRangeFromPoint, docSentenceExtract] = vm.get([ + const [DOMTextScanner, TextSourceRange, TextSourceElement, docRangeFromPoint, docSentenceExtract] = vm.get([ + 'DOMTextScanner', 'TextSourceRange', 'TextSourceElement', 'docRangeFromPoint', @@ -106,7 +108,7 @@ async function testDocument1() { try { await testDocumentTextScanningFunctions(dom, {docRangeFromPoint, docSentenceExtract, TextSourceRange, TextSourceElement}); - await testTextSourceRangeSeekFunctions(dom, {TextSourceRange}); + await testTextSourceRangeSeekFunctions(dom, {DOMTextScanner}); } finally { window.close(); } @@ -179,7 +181,7 @@ async function testDocumentTextScanningFunctions(dom, {docRangeFromPoint, docSen if (source === null) { continue; } // Test docSentenceExtract - const sentenceActual = docSentenceExtract(source, sentenceExtent).text; + const sentenceActual = docSentenceExtract(source, sentenceExtent, false).text; assert.strictEqual(sentenceActual, sentence); // Clean @@ -187,7 +189,7 @@ async function testDocumentTextScanningFunctions(dom, {docRangeFromPoint, docSen } } -async function testTextSourceRangeSeekFunctions(dom, {TextSourceRange}) { +async function testTextSourceRangeSeekFunctions(dom, {DOMTextScanner}) { const document = dom.window.document; for (const testElement of document.querySelectorAll('.test[data-test-type=text-source-range-seek]')) { @@ -220,8 +222,8 @@ async function testTextSourceRangeSeekFunctions(dom, {TextSourceRange}) { const {node, offset, content} = ( seekDirection === 'forward' ? - TextSourceRange.seekForward(seekNode, seekOffset, seekLength) : - TextSourceRange.seekBackward(seekNode, seekOffset, seekLength) + new DOMTextScanner(seekNode, seekOffset, true, false).seek(seekLength) : + new DOMTextScanner(seekNode, seekOffset, true, false).seek(-seekLength) ); assert.strictEqual(node, expectedResultNode); |