summaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
Diffstat (limited to 'ext')
-rw-r--r--ext/bg/background.html5
-rw-r--r--ext/bg/context.html2
-rw-r--r--ext/bg/css/settings.css4
-rw-r--r--ext/bg/data/options-schema.json533
-rw-r--r--ext/bg/js/anki.js4
-rw-r--r--ext/bg/js/api.js498
-rw-r--r--ext/bg/js/audio.js43
-rw-r--r--ext/bg/js/backend-api-forwarder.js4
-rw-r--r--ext/bg/js/backend.js614
-rw-r--r--ext/bg/js/conditions.js4
-rw-r--r--ext/bg/js/context.js4
-rw-r--r--ext/bg/js/database.js57
-rw-r--r--ext/bg/js/deinflector.js4
-rw-r--r--ext/bg/js/dictionary.js12
-rw-r--r--ext/bg/js/handlebars.js15
-rw-r--r--ext/bg/js/json-schema.js428
-rw-r--r--ext/bg/js/mecab.js4
-rw-r--r--ext/bg/js/options.js32
-rw-r--r--ext/bg/js/page-exit-prevention.js4
-rw-r--r--ext/bg/js/profile-conditions.js4
-rw-r--r--ext/bg/js/request.js4
-rw-r--r--ext/bg/js/search-frontend.js8
-rw-r--r--ext/bg/js/search-query-parser.js15
-rw-r--r--ext/bg/js/search.js144
-rw-r--r--ext/bg/js/settings/anki-templates.js40
-rw-r--r--ext/bg/js/settings/anki.js6
-rw-r--r--ext/bg/js/settings/audio-ui.js (renamed from ext/bg/js/audio-ui.js)44
-rw-r--r--ext/bg/js/settings/audio.js52
-rw-r--r--ext/bg/js/settings/backup.js370
-rw-r--r--ext/bg/js/settings/conditions-ui.js (renamed from ext/bg/js/conditions-ui.js)4
-rw-r--r--ext/bg/js/settings/dictionaries.js57
-rw-r--r--ext/bg/js/settings/main.js40
-rw-r--r--ext/bg/js/settings/popup-preview-frame.js47
-rw-r--r--ext/bg/js/settings/popup-preview.js4
-rw-r--r--ext/bg/js/settings/profiles.js20
-rw-r--r--ext/bg/js/settings/storage.js4
-rw-r--r--ext/bg/js/templates.js8
-rw-r--r--ext/bg/js/translator.js12
-rw-r--r--ext/bg/js/util.js105
-rw-r--r--ext/bg/legal.html4
-rw-r--r--ext/bg/search.html3
-rw-r--r--ext/bg/settings-popup-preview.html4
-rw-r--r--ext/bg/settings.html132
-rw-r--r--ext/fg/css/client.css4
-rw-r--r--ext/fg/float.html2
-rw-r--r--ext/fg/js/document.js13
-rw-r--r--ext/fg/js/float.js53
-rw-r--r--ext/fg/js/frontend-api-receiver.js21
-rw-r--r--ext/fg/js/frontend-api-sender.js27
-rw-r--r--ext/fg/js/frontend-initialize.js21
-rw-r--r--ext/fg/js/frontend.js425
-rw-r--r--ext/fg/js/popup-nested.js5
-rw-r--r--ext/fg/js/popup-proxy-host.js161
-rw-r--r--ext/fg/js/popup-proxy.js111
-rw-r--r--ext/fg/js/popup.js524
-rw-r--r--ext/fg/js/source.js4
-rw-r--r--ext/manifest.json5
-rw-r--r--ext/mixed/css/display-dark.css4
-rw-r--r--ext/mixed/css/display-default.css4
-rw-r--r--ext/mixed/css/display.css9
-rw-r--r--ext/mixed/js/api.js (renamed from ext/fg/js/api.js)18
-rw-r--r--ext/mixed/js/audio.js4
-rw-r--r--ext/mixed/js/core.js101
-rw-r--r--ext/mixed/js/display-context.js4
-rw-r--r--ext/mixed/js/display.js101
-rw-r--r--ext/mixed/js/dom.js4
-rw-r--r--ext/mixed/js/japanese.js13
-rw-r--r--ext/mixed/js/scroll.js4
-rw-r--r--ext/mixed/js/text-scanner.js378
-rw-r--r--ext/mixed/js/timer.js96
70 files changed, 3800 insertions, 1718 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html
index 5a6970c3..af87eddb 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -12,7 +12,7 @@
<link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
</head>
<body>
- <div id="clipboard-paste-target" contenteditable="true"></div>
+ <textarea id="clipboard-paste-target"></textarea>
<script src="/mixed/lib/handlebars.min.js"></script>
<script src="/mixed/lib/jszip.min.js"></script>
@@ -22,8 +22,8 @@
<script src="/mixed/js/dom.js"></script>
<script src="/bg/js/anki.js"></script>
- <script src="/bg/js/mecab.js"></script>
<script src="/bg/js/api.js"></script>
+ <script src="/bg/js/mecab.js"></script>
<script src="/bg/js/audio.js"></script>
<script src="/bg/js/backend-api-forwarder.js"></script>
<script src="/bg/js/conditions.js"></script>
@@ -31,6 +31,7 @@
<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/options.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
<script src="/bg/js/request.js"></script>
diff --git a/ext/bg/context.html b/ext/bg/context.html
index eda09a68..0e50ed7c 100644
--- a/ext/bg/context.html
+++ b/ext/bg/context.html
@@ -180,8 +180,8 @@
<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/api.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/util.js"></script>
diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css
index 8adae47c..63cead6b 100644
--- a/ext/bg/css/settings.css
+++ b/ext/bg/css/settings.css
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json
new file mode 100644
index 00000000..c086052b
--- /dev/null
+++ b/ext/bg/data/options-schema.json
@@ -0,0 +1,533 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "required": [
+ "version",
+ "profiles",
+ "profileCurrent",
+ "global"
+ ],
+ "properties": {
+ "version": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0
+ },
+ "profiles": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "conditionGroups",
+ "options"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "default": "Default"
+ },
+ "conditionGroups": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "conditions"
+ ],
+ "properties": {
+ "conditions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "type",
+ "operator",
+ "value"
+ ],
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "operator": {
+ "type": "string"
+ },
+ "value": {}
+ }
+ }
+ }
+ }
+ }
+ },
+ "options": {
+ "type": "object",
+ "required": [
+ "version",
+ "general",
+ "audio",
+ "scanning",
+ "dictionaries",
+ "parsing",
+ "anki"
+ ],
+ "properties": {
+ "version": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "general": {
+ "type": "object",
+ "required": [
+ "enable",
+ "resultOutputMode",
+ "debugInfo",
+ "maxResults",
+ "showAdvanced",
+ "popupDisplayMode",
+ "popupWidth",
+ "popupHeight",
+ "popupHorizontalOffset",
+ "popupVerticalOffset",
+ "popupHorizontalOffset2",
+ "popupVerticalOffset2",
+ "popupHorizontalTextPosition",
+ "popupVerticalTextPosition",
+ "showGuide",
+ "compactTags",
+ "compactGlossaries",
+ "mainDictionary",
+ "popupTheme",
+ "popupOuterTheme",
+ "customPopupCss",
+ "customPopupOuterCss",
+ "enableWanakana",
+ "enableClipboardMonitor"
+ ],
+ "properties": {
+ "enable": {
+ "type": "boolean",
+ "default": true
+ },
+ "resultOutputMode": {
+ "type": "string",
+ "enum": ["group", "merge", "split"],
+ "default": "group"
+ },
+ "debugInfo": {
+ "type": "boolean",
+ "default": false
+ },
+ "maxResults": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 32
+ },
+ "showAdvanced": {
+ "type": "boolean",
+ "default": false
+ },
+ "popupDisplayMode": {
+ "type": "string",
+ "enum": ["default", "full-width"],
+ "default": "default"
+ },
+ "popupWidth": {
+ "type": "number",
+ "minimum": 0,
+ "default": 400
+ },
+ "popupHeight": {
+ "type": "number",
+ "minimum": 0,
+ "default": 250
+ },
+ "popupHorizontalOffset": {
+ "type": "number",
+ "default": 0
+ },
+ "popupVerticalOffset": {
+ "type": "number",
+ "default": 10
+ },
+ "popupHorizontalOffset2": {
+ "type": "number",
+ "default": 10
+ },
+ "popupVerticalOffset2": {
+ "type": "number",
+ "default": 0
+ },
+ "popupHorizontalTextPosition": {
+ "type": "string",
+ "enum": ["below", "above"],
+ "default": "below"
+ },
+ "popupVerticalTextPosition": {
+ "type": "string",
+ "enum": ["default", "before", "after", "left", "right"],
+ "default": "before"
+ },
+ "showGuide": {
+ "type": "boolean",
+ "default": true
+ },
+ "compactTags": {
+ "type": "boolean",
+ "default": false
+ },
+ "compactGlossaries": {
+ "type": "boolean",
+ "default": false
+ },
+ "mainDictionary": {
+ "type": "string"
+ },
+ "popupTheme": {
+ "type": "string",
+ "enum": ["default", "dark"],
+ "default": "default"
+ },
+ "popupOuterTheme": {
+ "type": "string",
+ "enum": ["default", "dark", "auto"],
+ "default": "default"
+ },
+ "customPopupCss": {
+ "type": "string",
+ "default": ""
+ },
+ "customPopupOuterCss": {
+ "type": "string",
+ "default": ""
+ },
+ "enableWanakana": {
+ "type": "boolean",
+ "default": true
+ },
+ "enableClipboardMonitor": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
+ "audio": {
+ "type": "object",
+ "required": [
+ "enabled",
+ "sources",
+ "volume",
+ "autoPlay",
+ "customSourceUrl",
+ "textToSpeechVoice"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": true
+ },
+ "sources": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "jpod101",
+ "jpod101-alternate",
+ "jisho",
+ "text-to-speech",
+ "text-to-speech-reading",
+ "custom"
+ ],
+ "default": "jpod101"
+ }
+ },
+ "volume": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 100,
+ "default": 100
+ },
+ "autoPlay": {
+ "type": "boolean",
+ "default": false
+ },
+ "customSourceUrl": {
+ "type": "string",
+ "default": ""
+ },
+ "textToSpeechVoice": {
+ "type": "string",
+ "default": ""
+ }
+ }
+ },
+ "scanning": {
+ "type": "object",
+ "required": [
+ "middleMouse",
+ "touchInputEnabled",
+ "selectText",
+ "alphanumeric",
+ "autoHideResults",
+ "delay",
+ "length",
+ "modifier",
+ "deepDomScan",
+ "popupNestingMaxDepth",
+ "enablePopupSearch",
+ "enableOnPopupExpressions",
+ "enableOnSearchPage"
+ ],
+ "properties": {
+ "middleMouse": {
+ "type": "boolean",
+ "default": true
+ },
+ "touchInputEnabled": {
+ "type": "boolean",
+ "default": true
+ },
+ "selectText": {
+ "type": "boolean",
+ "default": true
+ },
+ "alphanumeric": {
+ "type": "boolean",
+ "default": true
+ },
+ "autoHideResults": {
+ "type": "boolean",
+ "default": false
+ },
+ "delay": {
+ "type": "number",
+ "minimum": 0,
+ "default": 20
+ },
+ "length": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 10
+ },
+ "modifier": {
+ "type": "string",
+ "enum": ["none", "alt", "ctrl", "shift"],
+ "default": "shift"
+ },
+ "deepDomScan": {
+ "type": "boolean",
+ "default": false
+ },
+ "popupNestingMaxDepth": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0
+ },
+ "enablePopupSearch": {
+ "type": "boolean",
+ "default": false
+ },
+ "enableOnPopupExpressions": {
+ "type": "boolean",
+ "default": false
+ },
+ "enableOnSearchPage": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ },
+ "dictionaries": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "required": [
+ "priority",
+ "enabled",
+ "allowSecondarySearches"
+ ],
+ "properties": {
+ "priority": {
+ "type": "number",
+ "default": 0
+ },
+ "enabled": {
+ "type": "boolean",
+ "default": true
+ },
+ "allowSecondarySearches": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ }
+ },
+ "parsing": {
+ "type": "object",
+ "required": [
+ "enableScanningParser",
+ "enableMecabParser",
+ "selectedParser",
+ "readingMode"
+ ],
+ "properties": {
+ "enableScanningParser": {
+ "type": "boolean",
+ "default": true
+ },
+ "enableMecabParser": {
+ "type": "boolean",
+ "default": false
+ },
+ "selectedParser": {
+ "type": ["string", "null"],
+ "default": null
+ },
+ "readingMode": {
+ "type": "string",
+ "enum": ["hiragana", "katakana", "romaji"],
+ "default": "hiragana"
+ }
+ }
+ },
+ "anki": {
+ "type": "object",
+ "required": [
+ "enable",
+ "server",
+ "tags",
+ "sentenceExt",
+ "screenshot",
+ "terms",
+ "kanji",
+ "fieldTemplates"
+ ],
+ "properties": {
+ "enable": {
+ "type": "boolean",
+ "default": false
+ },
+ "server": {
+ "type": "string",
+ "default": "http://127.0.0.1:8765"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [
+ "yomichan"
+ ]
+ },
+ "sentenceExt": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 200
+ },
+ "screenshot": {
+ "type": "object",
+ "required": [
+ "format",
+ "quality"
+ ],
+ "properties": {
+ "format": {
+ "type": "string",
+ "enum": ["png", "jpeg"],
+ "default": "png"
+ },
+ "quality": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 100,
+ "default": 92
+ }
+ }
+ },
+ "terms": {
+ "type": "object",
+ "required": [
+ "deck",
+ "model",
+ "fields"
+ ],
+ "properties": {
+ "deck": {
+ "type": "string",
+ "default": ""
+ },
+ "model": {
+ "type": "string",
+ "default": ""
+ },
+ "fields": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "default": ""
+ }
+ }
+ }
+ },
+ "kanji": {
+ "type": "object",
+ "required": [
+ "deck",
+ "model",
+ "fields"
+ ],
+ "properties": {
+ "deck": {
+ "type": "string",
+ "default": ""
+ },
+ "model": {
+ "type": "string",
+ "default": ""
+ },
+ "fields": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "default": ""
+ }
+ }
+ }
+ },
+ "fieldTemplates": {
+ "type": ["string", "null"],
+ "default": null
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "profileCurrent": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0
+ },
+ "global": {
+ "type": "object",
+ "required": [
+ "database"
+ ],
+ "properties": {
+ "database": {
+ "type": "object",
+ "required": [
+ "prefixWildcardsSupported"
+ ],
+ "properties": {
+ "prefixWildcardsSupported": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js
index 17b93620..10a07061 100644
--- a/ext/bg/js/anki.js
+++ b/ext/bg/js/anki.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js
index b489b8d2..906aaa30 100644
--- a/ext/bg/js/api.js
+++ b/ext/bg/js/api.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,491 +13,39 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-function apiOptionsGet(optionsContext) {
- return utilBackend().getOptions(optionsContext);
+function apiTemplateRender(template, data, dynamic) {
+ return _apiInvoke('templateRender', {data, template, dynamic});
}
-async function apiOptionsSet(changedOptions, optionsContext, source) {
- const options = await apiOptionsGet(optionsContext);
-
- function getValuePaths(obj) {
- const valuePaths = [];
- const nodes = [{obj, path: []}];
- while (nodes.length > 0) {
- const node = nodes.pop();
- for (const key of Object.keys(node.obj)) {
- const path = node.path.concat(key);
- const obj = node.obj[key];
- if (obj !== null && typeof obj === 'object') {
- nodes.unshift({obj, path});
- } else {
- valuePaths.push([obj, path]);
- }
- }
- }
- return valuePaths;
- }
-
- function modifyOption(path, value, options) {
- let pivot = options;
- for (const key of path.slice(0, -1)) {
- if (!hasOwn(pivot, key)) {
- return false;
- }
- pivot = pivot[key];
- }
- pivot[path[path.length - 1]] = value;
- return true;
- }
-
- for (const [value, path] of getValuePaths(changedOptions)) {
- modifyOption(path, value, options);
- }
-
- await apiOptionsSave(source);
-}
-
-function apiOptionsGetFull() {
- return utilBackend().getFullOptions();
-}
-
-async function apiOptionsSave(source) {
- const backend = utilBackend();
- const options = await apiOptionsGetFull();
- await optionsSave(options);
- backend.onOptionsUpdated(source);
-}
-
-async function apiTermsFind(text, details, optionsContext) {
- const options = await apiOptionsGet(optionsContext);
- const [definitions, length] = await utilBackend().translator.findTerms(text, details, options);
- definitions.splice(options.general.maxResults);
- return {length, definitions};
+function apiAudioGetUrl(definition, source, optionsContext) {
+ return _apiInvoke('audioGetUrl', {definition, source, optionsContext});
}
-async function apiTextParse(text, optionsContext) {
- const options = await apiOptionsGet(optionsContext);
- const translator = utilBackend().translator;
-
- const results = [];
- while (text.length > 0) {
- const term = [];
- const [definitions, sourceLength] = await translator.findTermsInternal(
- text.slice(0, options.scanning.length),
- dictEnabledSet(options),
- options.scanning.alphanumeric,
- {}
- );
- if (definitions.length > 0) {
- dictTermsSort(definitions);
- const {expression, reading} = definitions[0];
- const source = text.slice(0, sourceLength);
- for (const {text, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) {
- const reading = jpConvertReading(text, furigana, options.parsing.readingMode);
- term.push({text, reading});
- }
- text = text.slice(source.length);
- } else {
- const reading = jpConvertReading(text[0], null, options.parsing.readingMode);
- term.push({text: text[0], reading});
- text = text.slice(1);
- }
- results.push(term);
- }
- return results;
-}
-
-async function apiTextParseMecab(text, optionsContext) {
- const options = await apiOptionsGet(optionsContext);
- const mecab = utilBackend().mecab;
-
- const results = {};
- const rawResults = await mecab.parseText(text);
- for (const mecabName in rawResults) {
- const result = [];
- for (const parsedLine of rawResults[mecabName]) {
- for (const {expression, reading, source} of parsedLine) {
- const term = [];
- if (expression !== null && reading !== null) {
- for (const {text, furigana} of jpDistributeFuriganaInflected(
- expression,
- jpKatakanaToHiragana(reading),
- source
- )) {
- const reading = jpConvertReading(text, furigana, options.parsing.readingMode);
- term.push({text, reading});
+function _apiInvoke(action, params={}) {
+ const data = {action, params};
+ return new Promise((resolve, reject) => {
+ try {
+ const callback = (response) => {
+ if (response !== null && typeof response === 'object') {
+ if (typeof response.error !== 'undefined') {
+ reject(jsonToError(response.error));
+ } else {
+ resolve(response.result);
}
} else {
- const reading = jpConvertReading(source, null, options.parsing.readingMode);
- term.push({text: source, reading});
- }
- result.push(term);
- }
- result.push([{text: '\n'}]);
- }
- results[mecabName] = result;
- }
- return results;
-}
-
-async function apiKanjiFind(text, optionsContext) {
- const options = await apiOptionsGet(optionsContext);
- const definitions = await utilBackend().translator.findKanji(text, options);
- definitions.splice(options.general.maxResults);
- return definitions;
-}
-
-async function apiDefinitionAdd(definition, mode, context, optionsContext) {
- const options = await apiOptionsGet(optionsContext);
-
- if (mode !== 'kanji') {
- await audioInject(
- definition,
- options.anki.terms.fields,
- options.audio.sources,
- optionsContext
- );
- }
-
- if (context && context.screenshot) {
- await apiInjectScreenshot(
- definition,
- options.anki.terms.fields,
- context.screenshot
- );
- }
-
- const note = await dictNoteFormat(definition, mode, options);
- return utilBackend().anki.addNote(note);
-}
-
-async function apiDefinitionsAddable(definitions, modes, optionsContext) {
- const options = await apiOptionsGet(optionsContext);
- const states = [];
-
- try {
- const notes = [];
- for (const definition of definitions) {
- for (const mode of modes) {
- const note = await dictNoteFormat(definition, mode, options);
- notes.push(note);
- }
- }
-
- const cannotAdd = [];
- const anki = utilBackend().anki;
- const results = await anki.canAddNotes(notes);
- for (let resultBase = 0; resultBase < results.length; resultBase += modes.length) {
- const state = {};
- for (let modeOffset = 0; modeOffset < modes.length; ++modeOffset) {
- const index = resultBase + modeOffset;
- const result = results[index];
- const info = {canAdd: result};
- state[modes[modeOffset]] = info;
- if (!result) {
- cannotAdd.push([notes[index], info]);
- }
- }
-
- states.push(state);
- }
-
- if (cannotAdd.length > 0) {
- const noteIdsArray = await anki.findNoteIds(cannotAdd.map((e) => e[0]));
- for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) {
- const noteIds = noteIdsArray[i];
- if (noteIds.length > 0) {
- cannotAdd[i][1].noteId = noteIds[0];
+ const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`;
+ reject(new Error(`${message} (${JSON.stringify(data)})`));
}
- }
- }
- } catch (e) {
- // NOP
- }
-
- return states;
-}
-
-async function apiNoteView(noteId) {
- return utilBackend().anki.guiBrowse(`nid:${noteId}`);
-}
-
-async function apiTemplateRender(template, data, dynamic) {
- if (dynamic) {
- return handlebarsRenderDynamic(template, data);
- } else {
- return handlebarsRenderStatic(template, data);
- }
-}
-
-async function apiCommandExec(command, params) {
- const handlers = apiCommandExec.handlers;
- if (hasOwn(handlers, command)) {
- const handler = handlers[command];
- handler(params);
- }
-}
-apiCommandExec.handlers = {
- search: async (params) => {
- const url = chrome.runtime.getURL('/bg/search.html');
- if (!(params && params.newTab)) {
- try {
- const tab = await apiFindTab(1000, (url2) => (
- url2 !== null &&
- url2.startsWith(url) &&
- (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#')
- ));
- if (tab !== null) {
- await apiFocusTab(tab);
- return;
- }
- } catch (e) {
- // NOP
- }
- }
- chrome.tabs.create({url});
- },
-
- help: () => {
- chrome.tabs.create({url: 'https://foosoft.net/projects/yomichan/'});
- },
-
- options: (params) => {
- if (!(params && params.newTab)) {
- chrome.runtime.openOptionsPage();
- } else {
- const manifest = chrome.runtime.getManifest();
- const url = chrome.runtime.getURL(manifest.options_ui.page);
- chrome.tabs.create({url});
- }
- },
-
- toggle: async () => {
- const optionsContext = {
- depth: 0,
- url: window.location.href
- };
- const options = await apiOptionsGet(optionsContext);
- options.general.enable = !options.general.enable;
- await apiOptionsSave('popup');
- }
-};
-
-async function apiAudioGetUrl(definition, source, optionsContext) {
- return audioGetUrl(definition, source, optionsContext);
-}
-
-async function apiInjectScreenshot(definition, fields, screenshot) {
- let usesScreenshot = false;
- for (const name in fields) {
- if (fields[name].includes('{screenshot}')) {
- usesScreenshot = true;
- break;
- }
- }
-
- if (!usesScreenshot) {
- return;
- }
-
- const dateToString = (date) => {
- const year = date.getUTCFullYear();
- const month = date.getUTCMonth().toString().padStart(2, '0');
- const day = date.getUTCDate().toString().padStart(2, '0');
- const hours = date.getUTCHours().toString().padStart(2, '0');
- const minutes = date.getUTCMinutes().toString().padStart(2, '0');
- const seconds = date.getUTCSeconds().toString().padStart(2, '0');
- return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`;
- };
-
- const now = new Date(Date.now());
- const filename = `yomichan_browser_screenshot_${definition.reading}_${dateToString(now)}.${screenshot.format}`;
- const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, '');
-
- try {
- await utilBackend().anki.storeMediaFile(filename, data);
- } catch (e) {
- return;
- }
-
- definition.screenshotFileName = filename;
-}
-
-function apiScreenshotGet(options, sender) {
- if (!(sender && sender.tab)) {
- return Promise.resolve();
- }
-
- const windowId = sender.tab.windowId;
- return new Promise((resolve) => {
- chrome.tabs.captureVisibleTab(windowId, options, (dataUrl) => resolve(dataUrl));
- });
-}
-
-function apiForward(action, params, sender) {
- if (!(sender && sender.tab)) {
- return Promise.resolve();
- }
-
- const tabId = sender.tab.id;
- return new Promise((resolve) => {
- chrome.tabs.sendMessage(tabId, {action, params}, (response) => resolve(response));
- });
-}
-
-function apiFrameInformationGet(sender) {
- const frameId = sender.frameId;
- return Promise.resolve({frameId});
-}
-
-function apiInjectStylesheet(css, sender) {
- if (!sender.tab) {
- return Promise.reject(new Error('Invalid tab'));
- }
-
- const tabId = sender.tab.id;
- const frameId = sender.frameId;
- const details = {
- code: css,
- runAt: 'document_start',
- cssOrigin: 'user',
- allFrames: false
- };
- if (typeof frameId === 'number') {
- details.frameId = frameId;
- }
-
- return new Promise((resolve, reject) => {
- chrome.tabs.insertCSS(tabId, details, () => {
- const e = chrome.runtime.lastError;
- if (e) {
- reject(new Error(e.message));
- } else {
- resolve();
- }
- });
- });
-}
-
-async function apiGetEnvironmentInfo() {
- const browser = await apiGetBrowser();
- const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
- return {
- browser,
- platform: {
- os: platform.os
- }
- };
-}
-
-async function apiGetBrowser() {
- if (EXTENSION_IS_BROWSER_EDGE) {
- return 'edge';
- }
- if (typeof browser !== 'undefined') {
- try {
- const info = await browser.runtime.getBrowserInfo();
- if (info.name === 'Fennec') {
- return 'firefox-mobile';
- }
+ };
+ const backend = window.yomichanBackend;
+ backend.onMessage({action, params}, null, callback);
} catch (e) {
- // NOP
+ reject(e);
+ yomichan.triggerOrphaned(e);
}
- return 'firefox';
- } else {
- return 'chrome';
- }
-}
-
-function apiGetTabUrl(tab) {
- return new Promise((resolve) => {
- chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => {
- let url = null;
- if (!chrome.runtime.lastError) {
- url = (response !== null && typeof response === 'object' && !Array.isArray(response) ? response.url : null);
- if (url !== null && typeof url !== 'string') {
- url = null;
- }
- }
- resolve({tab, url});
- });
});
}
-
-async function apiFindTab(timeout, checkUrl) {
- // This function works around the need to have the "tabs" permission to access tab.url.
- const tabs = await new Promise((resolve) => chrome.tabs.query({}, resolve));
- let matchPromiseResolve = null;
- const matchPromise = new Promise((resolve) => { matchPromiseResolve = resolve; });
-
- const checkTabUrl = ({tab, url}) => {
- if (checkUrl(url, tab)) {
- matchPromiseResolve(tab);
- }
- };
-
- const promises = [];
- for (const tab of tabs) {
- const promise = apiGetTabUrl(tab);
- promise.then(checkTabUrl);
- promises.push(promise);
- }
-
- const racePromises = [
- matchPromise,
- Promise.all(promises).then(() => null)
- ];
- if (typeof timeout === 'number') {
- racePromises.push(new Promise((resolve) => setTimeout(() => resolve(null), timeout)));
- }
-
- return await Promise.race(racePromises);
-}
-
-async function apiFocusTab(tab) {
- await new Promise((resolve, reject) => {
- chrome.tabs.update(tab.id, {active: true}, () => {
- const e = chrome.runtime.lastError;
- if (e) { reject(e); }
- else { resolve(); }
- });
- });
-
- if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) {
- // Windows not supported (e.g. on Firefox mobile)
- return;
- }
-
- try {
- const tabWindow = await new Promise((resolve) => {
- chrome.windows.get(tab.windowId, {}, (tabWindow) => {
- const e = chrome.runtime.lastError;
- if (e) { reject(e); }
- else { resolve(tabWindow); }
- });
- });
- if (!tabWindow.focused) {
- await new Promise((resolve, reject) => {
- chrome.windows.update(tab.windowId, {focused: true}, () => {
- const e = chrome.runtime.lastError;
- if (e) { reject(e); }
- else { resolve(); }
- });
- });
- }
- } catch (e) {
- // Edge throws exception for no reason here.
- }
-}
-
-async function apiClipboardGet() {
- const clipboardPasteTarget = utilBackend().clipboardPasteTarget;
- clipboardPasteTarget.innerText = '';
- clipboardPasteTarget.focus();
- document.execCommand('paste');
- return clipboardPasteTarget.innerText;
-}
diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio.js
index dc0ba5eb..36ac413b 100644
--- a/ext/bg/js/audio.js
+++ b/ext/bg/js/audio.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2017-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,12 +13,12 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-const audioUrlBuilders = {
- 'jpod101': async (definition) => {
+const audioUrlBuilders = new Map([
+ ['jpod101', async (definition) => {
let kana = definition.reading;
let kanji = definition.expression;
@@ -36,8 +36,8 @@ const audioUrlBuilders = {
}
return `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`;
- },
- 'jpod101-alternate': async (definition) => {
+ }],
+ ['jpod101-alternate', async (definition) => {
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post');
@@ -61,8 +61,8 @@ const audioUrlBuilders = {
}
throw new Error('Failed to find audio URL');
- },
- 'jisho': async (definition) => {
+ }],
+ ['jisho', async (definition) => {
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `https://jisho.org/search/${definition.expression}`);
@@ -85,37 +85,34 @@ const audioUrlBuilders = {
}
throw new Error('Failed to find audio URL');
- },
- 'text-to-speech': async (definition, optionsContext) => {
- const options = await apiOptionsGet(optionsContext);
+ }],
+ ['text-to-speech', async (definition, options) => {
const voiceURI = options.audio.textToSpeechVoice;
if (!voiceURI) {
throw new Error('No voice');
}
return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
- },
- 'text-to-speech-reading': async (definition, optionsContext) => {
- const options = await apiOptionsGet(optionsContext);
+ }],
+ ['text-to-speech-reading', async (definition, options) => {
const voiceURI = options.audio.textToSpeechVoice;
if (!voiceURI) {
throw new Error('No voice');
}
return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
- },
- 'custom': async (definition, optionsContext) => {
- const options = await apiOptionsGet(optionsContext);
+ }],
+ ['custom', async (definition, options) => {
const customSourceUrl = options.audio.customSourceUrl;
return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0));
- }
-};
+ }]
+]);
-async function audioGetUrl(definition, mode, optionsContext, download) {
- if (hasOwn(audioUrlBuilders, mode)) {
- const handler = audioUrlBuilders[mode];
+async function audioGetUrl(definition, mode, options, download) {
+ const handler = audioUrlBuilders.get(mode);
+ if (typeof handler === 'function') {
try {
- return await handler(definition, optionsContext, download);
+ return await handler(definition, options, download);
} catch (e) {
// NOP
}
diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js
index db4d30b9..170a6b32 100644
--- a/ext/bg/js/backend-api-forwarder.js
+++ b/ext/bg/js/backend-api-forwarder.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index d9f9b586..28b0201e 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -23,6 +23,7 @@ class Backend {
this.anki = new AnkiNull();
this.mecab = new Mecab();
this.options = null;
+ this.optionsSchema = null;
this.optionsContext = {
depth: 0,
url: window.location.href
@@ -38,11 +39,20 @@ class Backend {
async prepare() {
await this.translator.prepare();
+
+ this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET');
this.options = await optionsLoad();
+ try {
+ this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, this.options);
+ } catch (e) {
+ // This shouldn't happen, but catch errors just in case of bugs
+ logError(e);
+ }
+
this.onOptionsUpdated('background');
if (chrome.commands !== null && typeof chrome.commands === 'object') {
- chrome.commands.onCommand.addListener(this.onCommand.bind(this));
+ chrome.commands.onCommand.addListener((command) => this._runCommand(command));
}
chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
@@ -67,22 +77,21 @@ class Backend {
});
}
- onCommand(command) {
- apiCommandExec(command);
- }
-
onMessage({action, params}, sender, callback) {
- const handlers = Backend.messageHandlers;
- if (hasOwn(handlers, action)) {
- const handler = handlers[action];
- const promise = handler(params, sender);
+ const handler = Backend._messageHandlers.get(action);
+ if (typeof handler !== 'function') { return false; }
+
+ try {
+ const promise = handler(this, params, sender);
promise.then(
(result) => callback({result}),
(error) => callback({error: errorToJson(error)})
);
+ return true;
+ } catch (error) {
+ callback({error: errorToJson(error)});
+ return false;
}
-
- return true;
}
applyOptions() {
@@ -106,6 +115,13 @@ class Backend {
}
}
+ async getOptionsSchema() {
+ if (this.isPreparedPromise !== null) {
+ await this.isPreparedPromise;
+ }
+ return this.optionsSchema;
+ }
+
async getFullOptions() {
if (this.isPreparedPromise !== null) {
await this.isPreparedPromise;
@@ -113,6 +129,18 @@ class Backend {
return this.options;
}
+ async setFullOptions(options) {
+ if (this.isPreparedPromise !== null) {
+ await this.isPreparedPromise;
+ }
+ try {
+ this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options));
+ } catch (e) {
+ // This shouldn't happen, but catch errors just in case of bugs
+ logError(e);
+ }
+ }
+
async getOptions(optionsContext) {
if (this.isPreparedPromise !== null) {
await this.isPreparedPromise;
@@ -180,28 +208,542 @@ class Backend {
checkLastError() {
// NOP
}
+
+ _runCommand(command, params) {
+ const handler = Backend._commandHandlers.get(command);
+ if (typeof handler !== 'function') { return false; }
+
+ handler(this, params);
+ return true;
+ }
+
+ // Message handlers
+
+ _onApiOptionsSchemaGet() {
+ return this.getOptionsSchema();
+ }
+
+ _onApiOptionsGet({optionsContext}) {
+ return this.getOptions(optionsContext);
+ }
+
+ _onApiOptionsGetFull() {
+ return this.getFullOptions();
+ }
+
+ async _onApiOptionsSet({changedOptions, optionsContext, source}) {
+ const options = await this.getOptions(optionsContext);
+
+ function getValuePaths(obj) {
+ const valuePaths = [];
+ const nodes = [{obj, path: []}];
+ while (nodes.length > 0) {
+ const node = nodes.pop();
+ for (const key of Object.keys(node.obj)) {
+ const path = node.path.concat(key);
+ const obj = node.obj[key];
+ if (obj !== null && typeof obj === 'object') {
+ nodes.unshift({obj, path});
+ } else {
+ valuePaths.push([obj, path]);
+ }
+ }
+ }
+ return valuePaths;
+ }
+
+ function modifyOption(path, value, options) {
+ let pivot = options;
+ for (const key of path.slice(0, -1)) {
+ if (!hasOwn(pivot, key)) {
+ return false;
+ }
+ pivot = pivot[key];
+ }
+ pivot[path[path.length - 1]] = value;
+ return true;
+ }
+
+ for (const [value, path] of getValuePaths(changedOptions)) {
+ modifyOption(path, value, options);
+ }
+
+ await this._onApiOptionsSave({source});
+ }
+
+ async _onApiOptionsSave({source}) {
+ const options = await this.getFullOptions();
+ await optionsSave(options);
+ this.onOptionsUpdated(source);
+ }
+
+ async _onApiKanjiFind({text, optionsContext}) {
+ const options = await this.getOptions(optionsContext);
+ const definitions = await this.translator.findKanji(text, options);
+ definitions.splice(options.general.maxResults);
+ return definitions;
+ }
+
+ async _onApiTermsFind({text, details, optionsContext}) {
+ const options = await this.getOptions(optionsContext);
+ const [definitions, length] = await this.translator.findTerms(text, details, options);
+ definitions.splice(options.general.maxResults);
+ return {length, definitions};
+ }
+
+ async _onApiTextParse({text, optionsContext}) {
+ const options = await this.getOptions(optionsContext);
+ const results = [];
+ while (text.length > 0) {
+ const term = [];
+ const [definitions, sourceLength] = await this.translator.findTermsInternal(
+ text.substring(0, options.scanning.length),
+ dictEnabledSet(options),
+ options.scanning.alphanumeric,
+ {}
+ );
+ if (definitions.length > 0) {
+ dictTermsSort(definitions);
+ const {expression, reading} = definitions[0];
+ const source = text.substring(0, sourceLength);
+ for (const {text, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) {
+ const reading = jpConvertReading(text, furigana, options.parsing.readingMode);
+ term.push({text, reading});
+ }
+ text = text.substring(source.length);
+ } else {
+ const reading = jpConvertReading(text[0], null, options.parsing.readingMode);
+ term.push({text: text[0], reading});
+ text = text.substring(1);
+ }
+ results.push(term);
+ }
+ return results;
+ }
+
+ async _onApiTextParseMecab({text, optionsContext}) {
+ const options = await this.getOptions(optionsContext);
+ const results = {};
+ const rawResults = await this.mecab.parseText(text);
+ for (const mecabName in rawResults) {
+ const result = [];
+ for (const parsedLine of rawResults[mecabName]) {
+ for (const {expression, reading, source} of parsedLine) {
+ const term = [];
+ if (expression !== null && reading !== null) {
+ for (const {text, furigana} of jpDistributeFuriganaInflected(
+ expression,
+ jpKatakanaToHiragana(reading),
+ source
+ )) {
+ const reading = jpConvertReading(text, furigana, options.parsing.readingMode);
+ term.push({text, reading});
+ }
+ } else {
+ const reading = jpConvertReading(source, null, options.parsing.readingMode);
+ term.push({text: source, reading});
+ }
+ result.push(term);
+ }
+ result.push([{text: '\n'}]);
+ }
+ results[mecabName] = result;
+ }
+ return results;
+ }
+
+ async _onApiDefinitionAdd({definition, mode, context, optionsContext}) {
+ const options = await this.getOptions(optionsContext);
+ const templates = Backend._getTemplates(options);
+
+ if (mode !== 'kanji') {
+ await audioInject(
+ definition,
+ options.anki.terms.fields,
+ options.audio.sources,
+ optionsContext
+ );
+ }
+
+ if (context && context.screenshot) {
+ await this._injectScreenshot(
+ definition,
+ options.anki.terms.fields,
+ context.screenshot
+ );
+ }
+
+ const note = await dictNoteFormat(definition, mode, options, templates);
+ return this.anki.addNote(note);
+ }
+
+ async _onApiDefinitionsAddable({definitions, modes, optionsContext}) {
+ const options = await this.getOptions(optionsContext);
+ const templates = Backend._getTemplates(options);
+ const states = [];
+
+ try {
+ const notes = [];
+ for (const definition of definitions) {
+ for (const mode of modes) {
+ const note = await dictNoteFormat(definition, mode, options, templates);
+ notes.push(note);
+ }
+ }
+
+ const cannotAdd = [];
+ const results = await this.anki.canAddNotes(notes);
+ for (let resultBase = 0; resultBase < results.length; resultBase += modes.length) {
+ const state = {};
+ for (let modeOffset = 0; modeOffset < modes.length; ++modeOffset) {
+ const index = resultBase + modeOffset;
+ const result = results[index];
+ const info = {canAdd: result};
+ state[modes[modeOffset]] = info;
+ if (!result) {
+ cannotAdd.push([notes[index], info]);
+ }
+ }
+
+ states.push(state);
+ }
+
+ if (cannotAdd.length > 0) {
+ const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0]));
+ for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) {
+ const noteIds = noteIdsArray[i];
+ if (noteIds.length > 0) {
+ cannotAdd[i][1].noteId = noteIds[0];
+ }
+ }
+ }
+ } catch (e) {
+ // NOP
+ }
+
+ return states;
+ }
+
+ async _onApiNoteView({noteId}) {
+ return this.anki.guiBrowse(`nid:${noteId}`);
+ }
+
+ async _onApiTemplateRender({template, data, dynamic}) {
+ return (
+ dynamic ?
+ handlebarsRenderDynamic(template, data) :
+ handlebarsRenderStatic(template, data)
+ );
+ }
+
+ async _onApiCommandExec({command, params}) {
+ return this._runCommand(command, params);
+ }
+
+ async _onApiAudioGetUrl({definition, source, optionsContext}) {
+ const options = await this.getOptions(optionsContext);
+ return await audioGetUrl(definition, source, options);
+ }
+
+ _onApiScreenshotGet({options}, sender) {
+ if (!(sender && sender.tab)) {
+ return Promise.resolve();
+ }
+
+ const windowId = sender.tab.windowId;
+ return new Promise((resolve) => {
+ chrome.tabs.captureVisibleTab(windowId, options, (dataUrl) => resolve(dataUrl));
+ });
+ }
+
+ _onApiForward({action, params}, sender) {
+ if (!(sender && sender.tab)) {
+ return Promise.resolve();
+ }
+
+ const tabId = sender.tab.id;
+ return new Promise((resolve) => {
+ chrome.tabs.sendMessage(tabId, {action, params}, (response) => resolve(response));
+ });
+ }
+
+ _onApiFrameInformationGet(params, sender) {
+ const frameId = sender.frameId;
+ return Promise.resolve({frameId});
+ }
+
+ _onApiInjectStylesheet({css}, sender) {
+ if (!sender.tab) {
+ return Promise.reject(new Error('Invalid tab'));
+ }
+
+ const tabId = sender.tab.id;
+ const frameId = sender.frameId;
+ const details = {
+ code: css,
+ runAt: 'document_start',
+ cssOrigin: 'user',
+ allFrames: false
+ };
+ if (typeof frameId === 'number') {
+ details.frameId = frameId;
+ }
+
+ return new Promise((resolve, reject) => {
+ chrome.tabs.insertCSS(tabId, details, () => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ async _onApiGetEnvironmentInfo() {
+ const browser = await Backend._getBrowser();
+ const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
+ return {
+ browser,
+ platform: {
+ os: platform.os
+ }
+ };
+ }
+
+ async _onApiClipboardGet() {
+ const clipboardPasteTarget = this.clipboardPasteTarget;
+ clipboardPasteTarget.value = '';
+ clipboardPasteTarget.focus();
+ document.execCommand('paste');
+ const result = clipboardPasteTarget.value;
+ clipboardPasteTarget.value = '';
+ return result;
+ }
+
+ // Command handlers
+
+ async _onCommandSearch(params) {
+ const url = chrome.runtime.getURL('/bg/search.html');
+ if (!(params && params.newTab)) {
+ try {
+ const tab = await Backend._findTab(1000, (url2) => (
+ url2 !== null &&
+ url2.startsWith(url) &&
+ (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#')
+ ));
+ if (tab !== null) {
+ await Backend._focusTab(tab);
+ return;
+ }
+ } catch (e) {
+ // NOP
+ }
+ }
+ chrome.tabs.create({url});
+ }
+
+ _onCommandHelp() {
+ chrome.tabs.create({url: 'https://foosoft.net/projects/yomichan/'});
+ }
+
+ _onCommandOptions(params) {
+ if (!(params && params.newTab)) {
+ chrome.runtime.openOptionsPage();
+ } else {
+ const manifest = chrome.runtime.getManifest();
+ const url = chrome.runtime.getURL(manifest.options_ui.page);
+ chrome.tabs.create({url});
+ }
+ }
+
+ async _onCommandToggle() {
+ const optionsContext = {
+ depth: 0,
+ url: window.location.href
+ };
+ const source = 'popup';
+
+ const options = await this.getOptions(optionsContext);
+ options.general.enable = !options.general.enable;
+ await this._onApiOptionsSave({source});
+ }
+
+ // Utilities
+
+ async _injectScreenshot(definition, fields, screenshot) {
+ let usesScreenshot = false;
+ for (const name in fields) {
+ if (fields[name].includes('{screenshot}')) {
+ usesScreenshot = true;
+ break;
+ }
+ }
+
+ if (!usesScreenshot) {
+ return;
+ }
+
+ const dateToString = (date) => {
+ const year = date.getUTCFullYear();
+ const month = date.getUTCMonth().toString().padStart(2, '0');
+ const day = date.getUTCDate().toString().padStart(2, '0');
+ const hours = date.getUTCHours().toString().padStart(2, '0');
+ const minutes = date.getUTCMinutes().toString().padStart(2, '0');
+ const seconds = date.getUTCSeconds().toString().padStart(2, '0');
+ return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`;
+ };
+
+ const now = new Date(Date.now());
+ const filename = `yomichan_browser_screenshot_${definition.reading}_${dateToString(now)}.${screenshot.format}`;
+ const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, '');
+
+ try {
+ await this.anki.storeMediaFile(filename, data);
+ } catch (e) {
+ return;
+ }
+
+ definition.screenshotFileName = filename;
+ }
+
+ static _getTabUrl(tab) {
+ return new Promise((resolve) => {
+ chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => {
+ let url = null;
+ if (!chrome.runtime.lastError) {
+ url = (response !== null && typeof response === 'object' && !Array.isArray(response) ? response.url : null);
+ if (url !== null && typeof url !== 'string') {
+ url = null;
+ }
+ }
+ resolve({tab, url});
+ });
+ });
+ }
+
+ static async _findTab(timeout, checkUrl) {
+ // This function works around the need to have the "tabs" permission to access tab.url.
+ const tabs = await new Promise((resolve) => chrome.tabs.query({}, resolve));
+ let matchPromiseResolve = null;
+ const matchPromise = new Promise((resolve) => { matchPromiseResolve = resolve; });
+
+ const checkTabUrl = ({tab, url}) => {
+ if (checkUrl(url, tab)) {
+ matchPromiseResolve(tab);
+ }
+ };
+
+ const promises = [];
+ for (const tab of tabs) {
+ const promise = Backend._getTabUrl(tab);
+ promise.then(checkTabUrl);
+ promises.push(promise);
+ }
+
+ const racePromises = [
+ matchPromise,
+ Promise.all(promises).then(() => null)
+ ];
+ if (typeof timeout === 'number') {
+ racePromises.push(new Promise((resolve) => setTimeout(() => resolve(null), timeout)));
+ }
+
+ return await Promise.race(racePromises);
+ }
+
+ static async _focusTab(tab) {
+ await new Promise((resolve, reject) => {
+ chrome.tabs.update(tab.id, {active: true}, () => {
+ const e = chrome.runtime.lastError;
+ if (e) { reject(e); }
+ else { resolve(); }
+ });
+ });
+
+ if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) {
+ // Windows not supported (e.g. on Firefox mobile)
+ return;
+ }
+
+ try {
+ const tabWindow = await new Promise((resolve) => {
+ chrome.windows.get(tab.windowId, {}, (tabWindow) => {
+ const e = chrome.runtime.lastError;
+ if (e) { reject(e); }
+ else { resolve(tabWindow); }
+ });
+ });
+ if (!tabWindow.focused) {
+ await new Promise((resolve, reject) => {
+ chrome.windows.update(tab.windowId, {focused: true}, () => {
+ const e = chrome.runtime.lastError;
+ if (e) { reject(e); }
+ else { resolve(); }
+ });
+ });
+ }
+ } catch (e) {
+ // Edge throws exception for no reason here.
+ }
+ }
+
+ static async _getBrowser() {
+ if (EXTENSION_IS_BROWSER_EDGE) {
+ return 'edge';
+ }
+ if (typeof browser !== 'undefined') {
+ try {
+ const info = await browser.runtime.getBrowserInfo();
+ if (info.name === 'Fennec') {
+ return 'firefox-mobile';
+ }
+ } catch (e) {
+ // NOP
+ }
+ return 'firefox';
+ } else {
+ return 'chrome';
+ }
+ }
+
+ static _getTemplates(options) {
+ const templates = options.anki.fieldTemplates;
+ return typeof templates === 'string' ? templates : profileOptionsGetDefaultFieldTemplates();
+ }
}
-Backend.messageHandlers = {
- optionsGet: ({optionsContext}) => apiOptionsGet(optionsContext),
- optionsSet: ({changedOptions, optionsContext, source}) => apiOptionsSet(changedOptions, optionsContext, source),
- kanjiFind: ({text, optionsContext}) => apiKanjiFind(text, optionsContext),
- termsFind: ({text, details, optionsContext}) => apiTermsFind(text, details, optionsContext),
- textParse: ({text, optionsContext}) => apiTextParse(text, optionsContext),
- textParseMecab: ({text, optionsContext}) => apiTextParseMecab(text, optionsContext),
- definitionAdd: ({definition, mode, context, optionsContext}) => apiDefinitionAdd(definition, mode, context, optionsContext),
- definitionsAddable: ({definitions, modes, optionsContext}) => apiDefinitionsAddable(definitions, modes, optionsContext),
- noteView: ({noteId}) => apiNoteView(noteId),
- templateRender: ({template, data, dynamic}) => apiTemplateRender(template, data, dynamic),
- commandExec: ({command, params}) => apiCommandExec(command, params),
- audioGetUrl: ({definition, source, optionsContext}) => apiAudioGetUrl(definition, source, optionsContext),
- screenshotGet: ({options}, sender) => apiScreenshotGet(options, sender),
- forward: ({action, params}, sender) => apiForward(action, params, sender),
- frameInformationGet: (params, sender) => apiFrameInformationGet(sender),
- injectStylesheet: ({css}, sender) => apiInjectStylesheet(css, sender),
- getEnvironmentInfo: () => apiGetEnvironmentInfo(),
- clipboardGet: () => apiClipboardGet()
-};
-
-window.yomichan_backend = new Backend();
-window.yomichan_backend.prepare();
+Backend._messageHandlers = new Map([
+ ['optionsSchemaGet', (self, ...args) => self._onApiOptionsSchemaGet(...args)],
+ ['optionsGet', (self, ...args) => self._onApiOptionsGet(...args)],
+ ['optionsGetFull', (self, ...args) => self._onApiOptionsGetFull(...args)],
+ ['optionsSet', (self, ...args) => self._onApiOptionsSet(...args)],
+ ['optionsSave', (self, ...args) => self._onApiOptionsSave(...args)],
+ ['kanjiFind', (self, ...args) => self._onApiKanjiFind(...args)],
+ ['termsFind', (self, ...args) => self._onApiTermsFind(...args)],
+ ['textParse', (self, ...args) => self._onApiTextParse(...args)],
+ ['textParseMecab', (self, ...args) => self._onApiTextParseMecab(...args)],
+ ['definitionAdd', (self, ...args) => self._onApiDefinitionAdd(...args)],
+ ['definitionsAddable', (self, ...args) => self._onApiDefinitionsAddable(...args)],
+ ['noteView', (self, ...args) => self._onApiNoteView(...args)],
+ ['templateRender', (self, ...args) => self._onApiTemplateRender(...args)],
+ ['commandExec', (self, ...args) => self._onApiCommandExec(...args)],
+ ['audioGetUrl', (self, ...args) => self._onApiAudioGetUrl(...args)],
+ ['screenshotGet', (self, ...args) => self._onApiScreenshotGet(...args)],
+ ['forward', (self, ...args) => self._onApiForward(...args)],
+ ['frameInformationGet', (self, ...args) => self._onApiFrameInformationGet(...args)],
+ ['injectStylesheet', (self, ...args) => self._onApiInjectStylesheet(...args)],
+ ['getEnvironmentInfo', (self, ...args) => self._onApiGetEnvironmentInfo(...args)],
+ ['clipboardGet', (self, ...args) => self._onApiClipboardGet(...args)]
+]);
+
+Backend._commandHandlers = new Map([
+ ['search', (self, ...args) => self._onCommandSearch(...args)],
+ ['help', (self, ...args) => self._onCommandHelp(...args)],
+ ['options', (self, ...args) => self._onCommandOptions(...args)],
+ ['toggle', (self, ...args) => self._onCommandToggle(...args)]
+]);
+
+window.yomichanBackend = new Backend();
+window.yomichanBackend.prepare();
diff --git a/ext/bg/js/conditions.js b/ext/bg/js/conditions.js
index c0f0f301..d4d1c0e0 100644
--- a/ext/bg/js/conditions.js
+++ b/ext/bg/js/conditions.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js
index 0b21f662..834174bf 100644
--- a/ext/bg/js/context.js
+++ b/ext/bg/js/context.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2017-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js
index a20d5f15..42a143f3 100644
--- a/ext/bg/js/database.js
+++ b/ext/bg/js/database.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -28,7 +28,7 @@ class Database {
}
try {
- this.db = await Database.open('dict', 4, (db, transaction, oldVersion) => {
+ this.db = await Database.open('dict', 5, (db, transaction, oldVersion) => {
Database.upgrade(db, transaction, oldVersion, [
{
version: 2,
@@ -76,6 +76,15 @@ class Database {
indices: ['dictionary', 'expression', 'reading', 'sequence']
}
}
+ },
+ {
+ version: 5,
+ stores: {
+ terms: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse']
+ }
+ }
}
]);
});
@@ -143,14 +152,17 @@ class Database {
}
};
+ const useWildcard = !!wildcard;
+ const prefixWildcard = wildcard === 'prefix';
+
const dbTransaction = this.db.transaction(['terms'], 'readonly');
const dbTerms = dbTransaction.objectStore('terms');
- const dbIndex1 = dbTerms.index('expression');
- const dbIndex2 = dbTerms.index('reading');
+ const dbIndex1 = dbTerms.index(prefixWildcard ? 'expressionReverse' : 'expression');
+ const dbIndex2 = dbTerms.index(prefixWildcard ? 'readingReverse' : 'reading');
for (let i = 0; i < termList.length; ++i) {
- const term = termList[i];
- const query = wildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term);
+ 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)
@@ -320,9 +332,12 @@ class Database {
return result;
}
- async importDictionary(archive, progressCallback, exceptions) {
+ async importDictionary(archive, progressCallback, details) {
this.validate();
+ const errors = [];
+ const prefixWildcardsSupported = details.prefixWildcardsSupported;
+
const maxTransactionLength = 1000;
const bulkAdd = async (objectStoreName, items, total, current) => {
const db = this.db;
@@ -337,11 +352,7 @@ class Database {
const objectStore = transaction.objectStore(objectStoreName);
await Database.bulkAdd(objectStore, items, i, count);
} catch (e) {
- if (exceptions) {
- exceptions.push(e);
- } else {
- throw e;
- }
+ errors.push(e);
}
}
};
@@ -396,6 +407,13 @@ class Database {
}
}
+ if (prefixWildcardsSupported) {
+ for (const row of rows) {
+ row.expressionReverse = stringReverse(row.expression);
+ row.readingReverse = stringReverse(row.reading);
+ }
+ }
+
await bulkAdd('terms', rows, total, current);
};
@@ -475,15 +493,18 @@ class Database {
await bulkAdd('tagMeta', rows, total, current);
};
- return await Database.importDictionaryZip(
+ const result = await Database.importDictionaryZip(
archive,
indexDataLoaded,
termDataLoaded,
termMetaDataLoaded,
kanjiDataLoaded,
kanjiMetaDataLoaded,
- tagDataLoaded
+ tagDataLoaded,
+ details
);
+
+ return {result, errors};
}
validate() {
@@ -499,7 +520,8 @@ class Database {
termMetaDataLoaded,
kanjiDataLoaded,
kanjiMetaDataLoaded,
- tagDataLoaded
+ tagDataLoaded,
+ details
) {
const zip = await JSZip.loadAsync(archive);
@@ -517,7 +539,8 @@ class Database {
title: index.title,
revision: index.revision,
sequenced: index.sequenced,
- version: index.format || index.version
+ version: index.format || index.version,
+ prefixWildcardsSupported: !!details.prefixWildcardsSupported
};
await indexDataLoaded(summary);
diff --git a/ext/bg/js/deinflector.js b/ext/bg/js/deinflector.js
index 51f4723c..33b2a8b3 100644
--- a/ext/bg/js/deinflector.js
+++ b/ext/bg/js/deinflector.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js
index 0b35e32e..92adc532 100644
--- a/ext/bg/js/dictionary.js
+++ b/ext/bg/js/dictionary.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -310,7 +310,7 @@ function dictFieldSplit(field) {
return field.length === 0 ? [] : field.split(' ');
}
-async function dictFieldFormat(field, definition, mode, options, exceptions) {
+async function dictFieldFormat(field, definition, mode, options, templates, exceptions) {
const data = {
marker: null,
definition,
@@ -329,7 +329,7 @@ async function dictFieldFormat(field, definition, mode, options, exceptions) {
}
data.marker = marker;
try {
- return await apiTemplateRender(options.anki.fieldTemplates, data, true);
+ return await apiTemplateRender(templates, data, true);
} catch (e) {
if (exceptions) { exceptions.push(e); }
return `{${marker}-render-error}`;
@@ -357,7 +357,7 @@ dictFieldFormat.markers = new Set([
'url'
]);
-async function dictNoteFormat(definition, mode, options) {
+async function dictNoteFormat(definition, mode, options, templates) {
const note = {fields: {}, tags: options.anki.tags};
let fields = [];
@@ -391,7 +391,7 @@ async function dictNoteFormat(definition, mode, options) {
}
for (const name in fields) {
- note.fields[name] = await dictFieldFormat(fields[name], definition, mode, options);
+ note.fields[name] = await dictFieldFormat(fields[name], definition, mode, options, templates);
}
return note;
diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js
index 8f43cf9a..6d1581be 100644
--- a/ext/bg/js/handlebars.js
+++ b/ext/bg/js/handlebars.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -141,12 +141,13 @@ function handlebarsRenderStatic(name, data) {
function handlebarsRenderDynamic(template, data) {
handlebarsRegisterHelpers();
-
- Handlebars.yomichan_cache = Handlebars.yomichan_cache || {};
- let instance = Handlebars.yomichan_cache[template];
- if (!instance) {
- instance = Handlebars.yomichan_cache[template] = Handlebars.compile(template);
+ 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/json-schema.js b/ext/bg/js/json-schema.js
new file mode 100644
index 00000000..5d596a8b
--- /dev/null
+++ b/ext/bg/js/json-schema.js
@@ -0,0 +1,428 @@
+/*
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+
+class JsonSchemaProxyHandler {
+ constructor(schema) {
+ this._schema = schema;
+ }
+
+ getPrototypeOf(target) {
+ return Object.getPrototypeOf(target);
+ }
+
+ setPrototypeOf() {
+ throw new Error('setPrototypeOf not supported');
+ }
+
+ isExtensible(target) {
+ return Object.isExtensible(target);
+ }
+
+ preventExtensions(target) {
+ Object.preventExtensions(target);
+ return true;
+ }
+
+ getOwnPropertyDescriptor(target, property) {
+ return Object.getOwnPropertyDescriptor(target, property);
+ }
+
+ defineProperty() {
+ throw new Error('defineProperty not supported');
+ }
+
+ has(target, property) {
+ return property in target;
+ }
+
+ get(target, property) {
+ if (typeof property === 'symbol') {
+ return target[property];
+ }
+
+ if (Array.isArray(target)) {
+ if (typeof property === 'string' && /^\d+$/.test(property)) {
+ property = parseInt(property, 10);
+ } else if (typeof property === 'string') {
+ return target[property];
+ }
+ }
+
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property);
+ if (propertySchema === null) {
+ return;
+ }
+
+ const value = target[property];
+ return value !== null && typeof value === 'object' ? JsonSchema.createProxy(value, propertySchema) : value;
+ }
+
+ set(target, property, value) {
+ if (Array.isArray(target)) {
+ if (typeof property === 'string' && /^\d+$/.test(property)) {
+ property = parseInt(property, 10);
+ if (property > target.length) {
+ throw new Error('Array index out of range');
+ }
+ } else if (typeof property === 'string') {
+ target[property] = value;
+ return true;
+ }
+ }
+
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property);
+ if (propertySchema === null) {
+ throw new Error(`Property ${property} not supported`);
+ }
+
+ value = JsonSchema.isolate(value);
+
+ const error = JsonSchemaProxyHandler.validate(value, propertySchema);
+ if (error !== null) {
+ throw new Error(`Invalid value: ${error}`);
+ }
+
+ target[property] = value;
+ return true;
+ }
+
+ deleteProperty(target, property) {
+ const required = this._schema.required;
+ if (Array.isArray(required) && required.includes(property)) {
+ throw new Error(`${property} cannot be deleted`);
+ }
+ return Reflect.deleteProperty(target, property);
+ }
+
+ ownKeys(target) {
+ return Reflect.ownKeys(target);
+ }
+
+ apply() {
+ throw new Error('apply not supported');
+ }
+
+ construct() {
+ throw new Error('construct not supported');
+ }
+
+ static getPropertySchema(schema, property) {
+ const type = schema.type;
+ if (Array.isArray(type)) {
+ throw new Error(`Ambiguous property type for ${property}`);
+ }
+ switch (type) {
+ case 'object':
+ {
+ const properties = schema.properties;
+ if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) {
+ if (Object.prototype.hasOwnProperty.call(properties, property)) {
+ return properties[property];
+ }
+ }
+
+ const additionalProperties = schema.additionalProperties;
+ return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null;
+ }
+ case 'array':
+ {
+ const items = schema.items;
+ return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null;
+ }
+ default:
+ return null;
+ }
+ }
+
+ static validate(value, schema) {
+ const type = JsonSchemaProxyHandler.getValueType(value);
+ const schemaType = schema.type;
+ if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) {
+ return `Value type ${type} does not match schema type ${schemaType}`;
+ }
+
+ const schemaEnum = schema.enum;
+ if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) {
+ return 'Invalid enum value';
+ }
+
+ switch (type) {
+ case 'number':
+ return JsonSchemaProxyHandler.validateNumber(value, schema);
+ case 'string':
+ return JsonSchemaProxyHandler.validateString(value, schema);
+ case 'array':
+ return JsonSchemaProxyHandler.validateArray(value, schema);
+ case 'object':
+ return JsonSchemaProxyHandler.validateObject(value, schema);
+ default:
+ return null;
+ }
+ }
+
+ static validateNumber(value, schema) {
+ const multipleOf = schema.multipleOf;
+ if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) {
+ return `Number is not a multiple of ${multipleOf}`;
+ }
+
+ const minimum = schema.minimum;
+ if (typeof minimum === 'number' && value < minimum) {
+ return `Number is less than ${minimum}`;
+ }
+
+ const exclusiveMinimum = schema.exclusiveMinimum;
+ if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) {
+ return `Number is less than or equal to ${exclusiveMinimum}`;
+ }
+
+ const maximum = schema.maximum;
+ if (typeof maximum === 'number' && value > maximum) {
+ return `Number is greater than ${maximum}`;
+ }
+
+ const exclusiveMaximum = schema.exclusiveMaximum;
+ if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) {
+ return `Number is greater than or equal to ${exclusiveMaximum}`;
+ }
+
+ return null;
+ }
+
+ static validateString(value, schema) {
+ const minLength = schema.minLength;
+ if (typeof minLength === 'number' && value.length < minLength) {
+ return 'String length too short';
+ }
+
+ const maxLength = schema.minLength;
+ if (typeof maxLength === 'number' && value.length > maxLength) {
+ return 'String length too long';
+ }
+
+ return null;
+ }
+
+ static validateArray(value, schema) {
+ const minItems = schema.minItems;
+ if (typeof minItems === 'number' && value.length < minItems) {
+ return 'Array length too short';
+ }
+
+ const maxItems = schema.maxItems;
+ if (typeof maxItems === 'number' && value.length > maxItems) {
+ return 'Array length too long';
+ }
+
+ return null;
+ }
+
+ static validateObject(value, schema) {
+ const properties = new Set(Object.getOwnPropertyNames(value));
+
+ const required = schema.required;
+ if (Array.isArray(required)) {
+ for (const property of required) {
+ if (!properties.has(property)) {
+ return `Missing property ${property}`;
+ }
+ }
+ }
+
+ const minProperties = schema.minProperties;
+ if (typeof minProperties === 'number' && properties.length < minProperties) {
+ return 'Not enough object properties';
+ }
+
+ const maxProperties = schema.maxProperties;
+ if (typeof maxProperties === 'number' && properties.length > maxProperties) {
+ return 'Too many object properties';
+ }
+
+ for (const property of properties) {
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
+ if (propertySchema === null) {
+ return `No schema found for ${property}`;
+ }
+ const error = JsonSchemaProxyHandler.validate(value[property], propertySchema);
+ if (error !== null) {
+ return error;
+ }
+ }
+
+ return null;
+ }
+
+ static isValueTypeAny(value, type, schemaTypes) {
+ if (typeof schemaTypes === 'string') {
+ return JsonSchemaProxyHandler.isValueType(value, type, schemaTypes);
+ } else if (Array.isArray(schemaTypes)) {
+ for (const schemaType of schemaTypes) {
+ if (JsonSchemaProxyHandler.isValueType(value, type, schemaType)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ return true;
+ }
+
+ static isValueType(value, type, schemaType) {
+ return (
+ type === schemaType ||
+ (schemaType === 'integer' && Math.floor(value) === value)
+ );
+ }
+
+ static getValueType(value) {
+ const type = typeof value;
+ if (type === 'object') {
+ if (value === null) { return 'null'; }
+ if (Array.isArray(value)) { return 'array'; }
+ }
+ return type;
+ }
+
+ static valuesAreEqualAny(value1, valueList) {
+ for (const value2 of valueList) {
+ if (JsonSchemaProxyHandler.valuesAreEqual(value1, value2)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static valuesAreEqual(value1, value2) {
+ return value1 === value2;
+ }
+
+ static getDefaultTypeValue(type) {
+ if (typeof type === 'string') {
+ switch (type) {
+ case 'null':
+ return null;
+ case 'boolean':
+ return false;
+ case 'number':
+ case 'integer':
+ return 0;
+ case 'string':
+ return '';
+ case 'array':
+ return [];
+ case 'object':
+ return {};
+ }
+ }
+ return null;
+ }
+
+ static getValidValueOrDefault(schema, value) {
+ let type = JsonSchemaProxyHandler.getValueType(value);
+ const schemaType = schema.type;
+ if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) {
+ let assignDefault = true;
+
+ const schemaDefault = schema.default;
+ if (typeof schemaDefault !== 'undefined') {
+ value = JsonSchema.isolate(schemaDefault);
+ type = JsonSchemaProxyHandler.getValueType(value);
+ assignDefault = !JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType);
+ }
+
+ if (assignDefault) {
+ value = JsonSchemaProxyHandler.getDefaultTypeValue(schemaType);
+ type = JsonSchemaProxyHandler.getValueType(value);
+ }
+ }
+
+ switch (type) {
+ case 'object':
+ value = JsonSchemaProxyHandler.populateObjectDefaults(value, schema);
+ break;
+ case 'array':
+ value = JsonSchemaProxyHandler.populateArrayDefaults(value, schema);
+ break;
+ }
+
+ return value;
+ }
+
+ static populateObjectDefaults(value, schema) {
+ const properties = new Set(Object.getOwnPropertyNames(value));
+
+ const required = schema.required;
+ if (Array.isArray(required)) {
+ for (const property of required) {
+ properties.delete(property);
+
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
+ if (propertySchema === null) { continue; }
+ value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]);
+ }
+ }
+
+ for (const property of properties) {
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
+ if (propertySchema === null) {
+ Reflect.deleteProperty(value, property);
+ } else {
+ value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]);
+ }
+ }
+
+ return value;
+ }
+
+ static populateArrayDefaults(value, schema) {
+ for (let i = 0, ii = value.length; i < ii; ++i) {
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i);
+ if (propertySchema === null) { continue; }
+ value[i] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[i]);
+ }
+
+ return value;
+ }
+}
+
+class JsonSchema {
+ static createProxy(target, schema) {
+ return new Proxy(target, new JsonSchemaProxyHandler(schema));
+ }
+
+ static getValidValueOrDefault(schema, value) {
+ return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value);
+ }
+
+ static isolate(value) {
+ if (value === null) { return null; }
+
+ switch (typeof value) {
+ case 'boolean':
+ case 'number':
+ case 'string':
+ case 'bigint':
+ case 'symbol':
+ return value;
+ }
+
+ const stringValue = JSON.stringify(value);
+ return typeof stringValue === 'string' ? JSON.parse(stringValue) : null;
+ }
+}
diff --git a/ext/bg/js/mecab.js b/ext/bg/js/mecab.js
index 62111f73..8bcbb91c 100644
--- a/ext/bg/js/mecab.js
+++ b/ext/bg/js/mecab.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index e53a8a13..8021672b 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -86,6 +86,13 @@ const profileOptionsVersionUpdates = [
delete options.general.audioSource;
delete options.general.audioVolume;
delete options.general.autoPlayAudio;
+ },
+ (options) => {
+ // Version 12 changes:
+ // The preferred default value of options.anki.fieldTemplates has been changed to null.
+ if (utilStringHashCode(options.anki.fieldTemplates) === 1444379824) {
+ options.anki.fieldTemplates = null;
+ }
}
];
@@ -326,7 +333,7 @@ function profileOptionsCreateDefaults() {
screenshot: {format: 'png', quality: 92},
terms: {deck: '', model: '', fields: {}},
kanji: {deck: '', model: '', fields: {}},
- fieldTemplates: profileOptionsGetDefaultFieldTemplates()
+ fieldTemplates: null
}
};
}
@@ -378,7 +385,15 @@ function profileOptionsUpdateVersion(options) {
* ]
*/
-const optionsVersionUpdates = [];
+const optionsVersionUpdates = [
+ (options) => {
+ options.global = {
+ database: {
+ prefixWildcardsSupported: false
+ }
+ };
+ }
+];
function optionsUpdateVersion(options, defaultProfileOptions) {
// Ensure profiles is an array
@@ -423,6 +438,11 @@ function optionsUpdateVersion(options, defaultProfileOptions) {
profile.options = profileOptionsUpdateVersion(profile.options);
}
+ // Version
+ if (typeof options.version !== 'number') {
+ options.version = 0;
+ }
+
// Generic updates
return optionsGenericApplyUpdates(options, optionsVersionUpdates);
}
@@ -468,3 +488,7 @@ function optionsSave(options) {
});
});
}
+
+function optionsGetDefault() {
+ return optionsUpdateVersion({}, {});
+}
diff --git a/ext/bg/js/page-exit-prevention.js b/ext/bg/js/page-exit-prevention.js
index aee4e3c2..3a320db3 100644
--- a/ext/bg/js/page-exit-prevention.js
+++ b/ext/bg/js/page-exit-prevention.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js
index ebc6680a..1fd78e5d 100644
--- a/ext/bg/js/profile-conditions.js
+++ b/ext/bg/js/profile-conditions.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/request.js b/ext/bg/js/request.js
index 7d73d49b..b584c9a9 100644
--- a/ext/bg/js/request.js
+++ b/ext/bg/js/request.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2017-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js
index 6ba8467e..2fe50a13 100644
--- a/ext/bg/js/search-frontend.js
+++ b/ext/bg/js/search-frontend.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -33,6 +33,7 @@ async function searchFrontendSetup() {
window.frontendInitializationData = {depth: 1, ignoreNodes, proxy: false};
const scriptSrcs = [
+ '/mixed/js/text-scanner.js',
'/fg/js/frontend-api-receiver.js',
'/fg/js/popup.js',
'/fg/js/popup-proxy-host.js',
@@ -40,6 +41,9 @@ async function searchFrontendSetup() {
'/fg/js/frontend-initialize.js'
];
for (const src of scriptSrcs) {
+ const node = document.querySelector(`script[src='${src}']`);
+ if (node !== null) { continue; }
+
const script = document.createElement('script');
script.async = false;
script.src = src;
diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js
index 8dc2e30a..0b3eccbd 100644
--- a/ext/bg/js/search-query-parser.js
+++ b/ext/bg/js/search-query-parser.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -62,7 +62,7 @@ class QueryParser {
const scanningOptions = this.search.options.scanning;
const scanningModifier = scanningOptions.modifier;
if (!(
- Frontend.isScanningModifierPressed(scanningModifier, e) ||
+ TextScanner.isScanningModifierPressed(scanningModifier, e) ||
(scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary'))
)) {
return;
@@ -148,10 +148,9 @@ class QueryParser {
async setPreview(text) {
const previewTerms = [];
- while (text.length > 0) {
- const tempText = text.slice(0, 2);
- previewTerms.push([{text: Array.from(tempText)}]);
- text = text.slice(2);
+ for (let i = 0, ii = text.length; i < ii; i += 2) {
+ const tempText = text.substring(i, i + 2);
+ previewTerms.push([{text: tempText.split('')}]);
}
this.queryParser.innerHTML = await apiTemplateRender('query-parser.html', {
terms: previewTerms,
@@ -218,7 +217,7 @@ class QueryParser {
return result.map((term) => {
return term.filter((part) => part.text.trim()).map((part) => {
return {
- text: Array.from(part.text),
+ text: part.text.split(''),
reading: part.reading,
raw: !part.reading || !part.reading.trim()
};
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index fe48773f..a4103ef2 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,16 +13,9 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-
-let IS_FIREFOX = null;
-(async () => {
- const {browser} = await apiGetEnvironmentInfo();
- IS_FIREFOX = ['firefox', 'firefox-mobile'].includes(browser);
-})();
-
class DisplaySearch extends Display {
constructor() {
super(document.querySelector('#spinner'), document.querySelector('#content'));
@@ -43,8 +36,12 @@ class DisplaySearch extends Display {
this.introVisible = true;
this.introAnimationTimer = null;
- this.clipboardMonitorIntervalId = null;
- this.clipboardPrevText = null;
+ this.isFirefox = false;
+
+ this.clipboardMonitorTimerId = null;
+ this.clipboardMonitorTimerToken = null;
+ this.clipboardInterval = 250;
+ this.clipboardPreviousText = null;
}
static create() {
@@ -56,6 +53,7 @@ class DisplaySearch extends Display {
async prepare() {
try {
await this.initialize();
+ this.isFirefox = await DisplaySearch._isFirefox();
if (this.search !== null) {
this.search.addEventListener('click', (e) => this.onSearch(e), false);
@@ -207,10 +205,14 @@ class DisplaySearch extends Display {
async onSearchQueryUpdated(query, animate) {
try {
const details = {};
- const match = /[*\uff0a]+$/.exec(query);
+ const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(query);
if (match !== null) {
- details.wildcard = true;
- query = query.substring(0, query.length - match[0].length);
+ if (match[1]) {
+ details.wildcard = 'prefix';
+ } else if (match[3]) {
+ details.wildcard = 'suffix';
+ }
+ query = match[2];
}
const valid = (query.length > 0);
@@ -224,63 +226,81 @@ class DisplaySearch extends Display {
sentence: {text: query, offset: 0},
url: window.location.href
});
- this.setTitleText(query);
} else {
this.container.textContent = '';
}
+ this.setTitleText(query);
window.parent.postMessage('popupClose', '*');
} catch (e) {
this.onError(e);
}
}
- onRuntimeMessage({action, params}, sender, callback) {
- const handlers = DisplaySearch.runtimeMessageHandlers;
- if (hasOwn(handlers, action)) {
- const handler = handlers[action];
- const result = handler(this, params);
- callback(result);
- } else {
- return super.onRuntimeMessage({action, params}, sender, callback);
- }
- }
-
initClipboardMonitor() {
// ignore copy from search page
window.addEventListener('copy', () => {
- this.clipboardPrevText = document.getSelection().toString().trim();
+ this.clipboardPreviousText = document.getSelection().toString().trim();
});
}
startClipboardMonitor() {
- this.clipboardMonitorIntervalId = setInterval(async () => {
- let curText = null;
- // TODO get rid of this and figure out why apiClipboardGet doesn't work on Firefox
- if (IS_FIREFOX) {
- curText = (await navigator.clipboard.readText()).trim();
- } else if (IS_FIREFOX === false) {
- curText = (await apiClipboardGet()).trim();
- }
- if (curText && (curText !== this.clipboardPrevText) && jpIsJapaneseText(curText)) {
- if (this.isWanakanaEnabled()) {
- this.setQuery(window.wanakana.toKana(curText));
- } else {
- this.setQuery(curText);
+ // The token below is used as a unique identifier to ensure that a new clipboard monitor
+ // hasn't been started during the await call. The check below the await this.getClipboardText()
+ // call will exit early if the reference has changed.
+ const token = {};
+ const intervalCallback = async () => {
+ this.clipboardMonitorTimerId = null;
+
+ let text = await this.getClipboardText();
+ if (this.clipboardMonitorTimerToken !== token) { return; }
+
+ if (
+ typeof text === 'string' &&
+ (text = text.trim()).length > 0 &&
+ text !== this.clipboardPreviousText
+ ) {
+ this.clipboardPreviousText = text;
+ if (jpIsJapaneseText(text)) {
+ this.setQuery(this.isWanakanaEnabled() ? window.wanakana.toKana(text) : text);
+ window.history.pushState(null, '', `${window.location.pathname}?query=${encodeURIComponent(text)}`);
+ this.onSearchQueryUpdated(this.query.value, true);
}
+ }
- const queryString = curText.length > 0 ? `?query=${encodeURIComponent(curText)}` : '';
- window.history.pushState(null, '', `${window.location.pathname}${queryString}`);
- this.onSearchQueryUpdated(this.query.value, true);
+ this.clipboardMonitorTimerId = setTimeout(intervalCallback, this.clipboardInterval);
+ };
- this.clipboardPrevText = curText;
- }
- }, 100);
+ this.clipboardMonitorTimerToken = token;
+
+ intervalCallback();
}
stopClipboardMonitor() {
- if (this.clipboardMonitorIntervalId) {
- clearInterval(this.clipboardMonitorIntervalId);
- this.clipboardMonitorIntervalId = null;
+ this.clipboardMonitorTimerToken = null;
+ if (this.clipboardMonitorTimerId !== null) {
+ clearTimeout(this.clipboardMonitorTimerId);
+ this.clipboardMonitorTimerId = null;
+ }
+ }
+
+ async getClipboardText() {
+ /*
+ Notes:
+ apiClipboardGet doesn't work on Firefox because document.execCommand('paste')
+ results in an empty string on the web extension background page.
+ This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
+ Therefore, navigator.clipboard.readText() is used on Firefox.
+
+ navigator.clipboard.readText() can't be used in Chrome for two reasons:
+ * Requires page to be focused, else it rejects with an exception.
+ * When the page is focused, Chrome will request clipboard permission, despite already
+ being an extension with clipboard permissions. It effectively asks for the
+ non-extension permission for clipboard access.
+ */
+ try {
+ return this.isFirefox ? await navigator.clipboard.readText() : await apiClipboardGet();
+ } catch (e) {
+ return null;
}
}
@@ -360,22 +380,32 @@ class DisplaySearch extends Display {
setTitleText(text) {
// Chrome limits title to 1024 characters
if (text.length > 1000) {
- text = text.slice(0, 1000) + '...';
+ text = text.substring(0, 1000) + '...';
+ }
+
+ if (text.length === 0) {
+ document.title = 'Yomichan Search';
+ } else {
+ document.title = `${text} - Yomichan Search`;
}
- document.title = `${text} - Yomichan Search`;
}
static getSearchQueryFromLocation(url) {
const match = /^[^?#]*\?(?:[^&#]*&)?query=([^&#]*)/.exec(url);
return match !== null ? decodeURIComponent(match[1]) : null;
}
-}
-DisplaySearch.runtimeMessageHandlers = {
- getUrl: () => {
- return {url: window.location.href};
+ static async _isFirefox() {
+ const {browser} = await apiGetEnvironmentInfo();
+ switch (browser) {
+ case 'firefox':
+ case 'firefox-mobile':
+ return true;
+ default:
+ return false;
+ }
}
-};
+}
DisplaySearch.onKeyDownIgnoreKeys = {
'ANY_MOD': [
@@ -392,4 +422,4 @@ DisplaySearch.onKeyDownIgnoreKeys = {
'Shift': []
};
-window.yomichan_search = DisplaySearch.create();
+DisplaySearch.instance = DisplaySearch.create();
diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js
index 9cdfc134..5e74358f 100644
--- a/ext/bg/js/settings/anki-templates.js
+++ b/ext/bg/js/settings/anki-templates.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -42,10 +42,22 @@ function ankiTemplatesInitialize() {
node.addEventListener('click', onAnkiTemplateMarkerClicked, false);
}
- $('#field-templates').on('change', (e) => onAnkiTemplatesValidateCompile(e));
+ $('#field-templates').on('change', (e) => onAnkiFieldTemplatesChanged(e));
$('#field-template-render').on('click', (e) => onAnkiTemplateRender(e));
$('#field-templates-reset').on('click', (e) => onAnkiFieldTemplatesReset(e));
$('#field-templates-reset-confirm').on('click', (e) => onAnkiFieldTemplatesResetConfirm(e));
+
+ ankiTemplatesUpdateValue();
+}
+
+async function ankiTemplatesUpdateValue() {
+ const optionsContext = getOptionsContext();
+ const options = await apiOptionsGet(optionsContext);
+ let templates = options.anki.fieldTemplates;
+ if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); }
+ $('#field-templates').val(templates);
+
+ onAnkiTemplatesValidateCompile();
}
const ankiTemplatesValidateGetDefinition = (() => {
@@ -73,7 +85,9 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i
const definition = await ankiTemplatesValidateGetDefinition(text, optionsContext);
if (definition !== null) {
const options = await apiOptionsGet(optionsContext);
- result = await dictFieldFormat(field, definition, mode, options, exceptions);
+ let templates = options.anki.fieldTemplates;
+ if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); }
+ result = await dictFieldFormat(field, definition, mode, options, templates, exceptions);
}
} catch (e) {
exceptions.push(e);
@@ -89,6 +103,24 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i
}
}
+async function onAnkiFieldTemplatesChanged(e) {
+ // Get value
+ let templates = e.currentTarget.value;
+ if (templates === profileOptionsGetDefaultFieldTemplates()) {
+ // Default
+ templates = null;
+ }
+
+ // Overwrite
+ const optionsContext = getOptionsContext();
+ const options = await getOptionsMutable(optionsContext);
+ options.anki.fieldTemplates = templates;
+ await settingsSaveOptions();
+
+ // Compile
+ onAnkiTemplatesValidateCompile();
+}
+
function onAnkiTemplatesValidateCompile() {
const infoNode = document.querySelector('#field-template-compile-result');
ankiTemplatesValidate(infoNode, '{expression}', 'term-kanji', false, true);
diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js
index e1aabbaf..5f7989b8 100644
--- a/ext/bg/js/settings/anki.js
+++ b/ext/bg/js/settings/anki.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -154,7 +154,7 @@ async function _onAnkiModelChanged(e) {
}
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
options.anki[tabId].fields = utilBackgroundIsolate(fields);
await settingsSaveOptions();
diff --git a/ext/bg/js/audio-ui.js b/ext/bg/js/settings/audio-ui.js
index 381129ac..711c2291 100644
--- a/ext/bg/js/audio-ui.js
+++ b/ext/bg/js/settings/audio-ui.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -21,7 +21,7 @@ class AudioSourceUI {
static instantiateTemplate(templateSelector) {
const template = document.querySelector(templateSelector);
const content = document.importNode(template.content, true);
- return $(content.firstChild);
+ return content.firstChild;
}
}
@@ -32,13 +32,14 @@ AudioSourceUI.Container = class Container {
this.addButton = addButton;
this.children = [];
- this.container.empty();
+ this.container.textContent = '';
for (const audioSource of toIterable(audioSources)) {
this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length));
}
- this.addButton.on('click', () => this.onAddAudioSource());
+ this._clickListener = () => this.onAddAudioSource();
+ this.addButton.addEventListener('click', this._clickListener, false);
}
cleanup() {
@@ -46,8 +47,9 @@ AudioSourceUI.Container = class Container {
child.cleanup();
}
- this.addButton.off('click');
- this.container.empty();
+ this.addButton.removeEventListener('click', this._clickListener, false);
+ this.container.textContent = '';
+ this._clickListener = null;
}
save() {
@@ -98,20 +100,28 @@ AudioSourceUI.AudioSource = class AudioSource {
this.audioSource = audioSource;
this.index = index;
- this.container = AudioSourceUI.instantiateTemplate('#audio-source-template').appendTo(parent.container);
- this.select = this.container.find('.audio-source-select');
- this.removeButton = this.container.find('.audio-source-remove');
+ 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.val(audioSource);
+ this.select.value = audioSource;
- this.select.on('change', () => this.onSelectChanged());
- this.removeButton.on('click', () => this.onRemoveClicked());
+ this._selectChangeListener = () => this.onSelectChanged();
+ this._removeClickListener = () => this.onRemoveClicked();
+
+ this.select.addEventListener('change', this._selectChangeListener, false);
+ this.removeButton.addEventListener('click', this._removeClickListener, false);
+
+ parent.container.appendChild(this.container);
}
cleanup() {
- this.select.off('change');
- this.removeButton.off('click');
- this.container.remove();
+ 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() {
@@ -119,7 +129,7 @@ AudioSourceUI.AudioSource = class AudioSource {
}
onSelectChanged() {
- this.audioSource = this.select.val();
+ this.audioSource = this.select.value;
this.parent.audioSources[this.index] = this.audioSource;
this.save();
}
diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js
index f63551ed..cff3f521 100644
--- a/ext/bg/js/settings/audio.js
+++ b/ext/bg/js/settings/audio.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -21,8 +21,12 @@ let audioSourceUI = null;
async function audioSettingsInitialize() {
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
- audioSourceUI = new AudioSourceUI.Container(options.audio.sources, $('.audio-source-list'), $('.audio-source-add'));
+ 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();
@@ -34,24 +38,34 @@ function textToSpeechInitialize() {
speechSynthesis.addEventListener('voiceschanged', () => updateTextToSpeechVoices(), false);
updateTextToSpeechVoices();
- $('#text-to-speech-voice-test').on('click', () => textToSpeechTest());
+ document.querySelector('#text-to-speech-voice').addEventListener('change', (e) => onTextToSpeechVoiceChange(e), false);
+ document.querySelector('#text-to-speech-voice-test').addEventListener('click', () => textToSpeechTest(), false);
}
function updateTextToSpeechVoices() {
const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index}));
voices.sort(textToSpeechVoiceCompare);
- if (voices.length > 0) {
- $('#text-to-speech-voice-container').css('display', '');
- }
- const select = $('#text-to-speech-voice');
- select.empty();
- select.append($('<option>').val('').text('None'));
+ 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) {
- select.append($('<option>').val(voice.voiceURI).text(`${voice.name} (${voice.lang})`));
+ option = document.createElement('option');
+ option.value = voice.voiceURI;
+ option.textContent = `${voice.name} (${voice.lang})`;
+ fragment.appendChild(option);
}
- select.val(select.attr('data-value'));
+ const select = document.querySelector('#text-to-speech-voice');
+ select.textContent = '';
+ select.appendChild(fragment);
+ select.value = select.dataset.value;
}
function languageTagIsJapanese(languageTag) {
@@ -78,15 +92,13 @@ function textToSpeechVoiceCompare(a, b) {
if (bIsDefault) { return 1; }
}
- if (a.index < b.index) { return -1; }
- if (a.index > b.index) { return 1; }
- return 0;
+ return a.index - b.index;
}
function textToSpeechTest() {
try {
- const text = $('#text-to-speech-voice-test').attr('data-speech-text') || '';
- const voiceURI = $('#text-to-speech-voice').val();
+ const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || '';
+ const voiceURI = document.querySelector('#text-to-speech-voice').value;
const voice = audioGetTextToSpeechVoice(voiceURI);
if (voice === null) { return; }
@@ -100,3 +112,7 @@ function textToSpeechTest() {
// NOP
}
}
+
+function onTextToSpeechVoiceChange(e) {
+ e.currentTarget.dataset.value = e.currentTarget.value;
+}
diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js
new file mode 100644
index 00000000..becdc568
--- /dev/null
+++ b/ext/bg/js/settings/backup.js
@@ -0,0 +1,370 @@
+/*
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 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/>.
+ */
+
+
+// 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('');
+}
+
+async function _getSettingsExportData(date) {
+ const optionsFull = await apiOptionsGetFull();
+ const environment = await apiGetEnvironmentInfo();
+
+ const fieldTemplatesDefault = profileOptionsGetDefaultFieldTemplates();
+
+ // Format options
+ for (const {options} of optionsFull.profiles) {
+ if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) {
+ delete options.anki.fieldTemplates; // Default
+ }
+ }
+
+ 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;
+}
+
+function _saveBlob(blob, fileName) {
+ if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') {
+ if (navigator.msSaveBlob(blob)) {
+ return;
+ }
+ }
+
+ const blobUrl = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = fileName;
+ a.rel = 'noopener';
+ a.target = '_blank';
+
+ const revoke = () => {
+ URL.revokeObjectURL(blobUrl);
+ a.href = '';
+ _settingsExportRevoke = null;
+ };
+ _settingsExportRevoke = revoke;
+
+ a.dispatchEvent(new MouseEvent('click'));
+ setTimeout(revoke, 60000);
+}
+
+async function _onSettingsExportClick() {
+ if (_settingsExportRevoke !== null) {
+ _settingsExportRevoke();
+ _settingsExportRevoke = null;
+ }
+
+ const date = new Date(Date.now());
+
+ const token = {};
+ _settingsExportToken = token;
+ const data = await _getSettingsExportData(date);
+ if (_settingsExportToken !== token) {
+ // A new export has been started
+ return;
+ }
+ _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 function _settingsImportSetOptionsFull(optionsFull) {
+ return utilIsolate(await utilBackend().setFullOptions(
+ utilBackgroundIsolate(optionsFull)
+ ));
+}
+
+function _showSettingsImportError(error) {
+ logError(error);
+ document.querySelector('#settings-import-error-modal-message').textContent = `${error}`;
+ $('#settings-import-error-modal').modal('show');
+}
+
+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};
+ }
+
+ // Set message
+ const fragment = document.createDocumentFragment();
+ for (const warning of warnings) {
+ const node = document.createElement('li');
+ node.textContent = `${warning}`;
+ fragment.appendChild(node);
+ }
+ 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);
+ }
+
+ resolve(result);
+ };
+
+ // Hook events
+ modalNode.on('hide.bs.modal', onModalHide);
+ for (const button of buttons) {
+ button.addEventListener('click', onButtonClick, 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;
+ }
+ } catch (e) {
+ // NOP
+ }
+ return false;
+}
+
+function _settingsImportSanitizeProfileOptions(options, dryRun) {
+ const warnings = [];
+
+ 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;
+ }
+ }
+ }
+
+ 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;
+ }
+ }
+ }
+
+ return warnings;
+}
+
+function _settingsImportSanitizeOptions(optionsFull, dryRun) {
+ const warnings = new Set();
+
+ 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);
+ }
+ }
+ }
+
+ return warnings;
+}
+
+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));
+ }
+}
+
+async function _importSettingsFile(file) {
+ const dataString = _utf8Decode(await utilReadFileArrayBuffer(file));
+ const data = JSON.parse(dataString);
+
+ // Type check
+ if (!isObject(data)) {
+ throw new Error(`Invalid data type: ${typeof data}`);
+ }
+
+ // Version check
+ const version = data.version;
+ if (!(
+ typeof version === 'number' &&
+ Number.isFinite(version) &&
+ version === Math.floor(version)
+ )) {
+ throw new Error(`Invalid version: ${version}`);
+ }
+
+ if (!(
+ version >= 0 &&
+ version <= SETTINGS_EXPORT_CURRENT_VERSION
+ )) {
+ throw new Error(`Unsupported version: ${version}`);
+ }
+
+ // Verify options exists
+ let optionsFull = data.options;
+ if (!isObject(optionsFull)) {
+ throw new Error(`Invalid options type: ${typeof optionsFull}`);
+ }
+
+ // Upgrade options
+ optionsFull = optionsUpdateVersion(optionsFull, {});
+
+ // Check for warnings
+ const sanitizationWarnings = _settingsImportSanitizeOptions(optionsFull, true);
+
+ // Show sanitization warnings
+ if (sanitizationWarnings.size > 0) {
+ const {result, sanitize} = await _showSettingsImportWarnings(sanitizationWarnings);
+ if (!result) { return; }
+
+ if (sanitize !== false) {
+ _settingsImportSanitizeOptions(optionsFull, false);
+ }
+ }
+
+ // Assign options
+ await _settingsImportSetOptionsFull(optionsFull);
+
+ // Reload settings page
+ window.location.reload();
+}
+
+function _onSettingsImportClick() {
+ document.querySelector('#settings-import-file').click();
+}
+
+function _onSettingsImportFileChange(e) {
+ const files = e.target.files;
+ if (files.length === 0) { return; }
+
+ const file = files[0];
+ e.target.value = null;
+ _importSettingsFile(file).catch(_showSettingsImportError);
+}
+
+
+// Resetting
+
+function _onSettingsResetClick() {
+ $('#settings-reset-modal').modal('show');
+}
+
+async function _onSettingsResetConfirmClick() {
+ $('#settings-reset-modal').modal('hide');
+
+ // Get default options
+ const optionsFull = optionsGetDefault();
+
+ // Assign options
+ await _settingsImportSetOptionsFull(optionsFull);
+
+ // Reload settings page
+ window.location.reload();
+}
+
+
+// Setup
+
+window.addEventListener('DOMContentLoaded', () => {
+ 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);
+}, false);
diff --git a/ext/bg/js/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js
index cc9db087..4d041451 100644
--- a/ext/bg/js/conditions-ui.js
+++ b/ext/bg/js/settings/conditions-ui.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js
index 065a8abc..ed171ae9 100644
--- a/ext/bg/js/settings/dictionaries.js
+++ b/ext/bg/js/settings/dictionaries.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -189,6 +189,7 @@ class SettingsDictionaryEntryUI {
this.content.querySelector('.dict-title').textContent = this.dictionaryInfo.title;
this.content.querySelector('.dict-revision').textContent = `rev.${this.dictionaryInfo.revision}`;
+ this.content.querySelector('.dict-prefix-wildcard-searches-supported').checked = !!this.dictionaryInfo.prefixWildcardsSupported;
this.applyValues();
@@ -272,7 +273,7 @@ class SettingsDictionaryEntryUI {
progress.hidden = true;
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
onDatabaseUpdated(options);
}
}
@@ -356,9 +357,10 @@ async function dictSettingsInitialize() {
document.querySelector('#dict-file-button').addEventListener('click', (e) => onDictionaryImportButtonClick(e), false);
document.querySelector('#dict-file').addEventListener('change', (e) => onDictionaryImport(e), false);
document.querySelector('#dict-main').addEventListener('change', (e) => onDictionaryMainChanged(e), false);
+ document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', (e) => onDatabaseEnablePrefixWildcardSearchesChanged(e), false);
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
onDictionaryOptionsChanged(options);
onDatabaseUpdated(options);
}
@@ -366,6 +368,9 @@ async function dictSettingsInitialize() {
async function onDictionaryOptionsChanged(options) {
if (dictionaryUI === null) { return; }
dictionaryUI.setOptionsDictionaries(options.dictionaries);
+
+ const optionsFull = await apiOptionsGetFull();
+ document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported;
}
async function onDatabaseUpdated(options) {
@@ -420,7 +425,7 @@ async function updateMainDictionarySelect(options, dictionaries) {
async function onDictionaryMainChanged(e) {
const value = e.target.value;
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
options.general.mainDictionary = value;
settingsSaveOptions();
}
@@ -526,14 +531,14 @@ async function onDictionaryPurge(e) {
dictionarySpinnerShow(true);
await utilDatabasePurge();
- for (const options of toIterable(await getOptionsArray())) {
+ for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) {
options.dictionaries = utilBackgroundIsolate({});
options.general.mainDictionary = '';
}
await settingsSaveOptions();
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
onDatabaseUpdated(options);
} catch (err) {
dictionaryErrorsShow([err]);
@@ -552,6 +557,9 @@ async function onDictionaryPurge(e) {
}
async function onDictionaryImport(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();
@@ -572,8 +580,11 @@ async function onDictionaryImport(e) {
}
};
- const exceptions = [];
- const files = [...e.target.files];
+ const optionsFull = await apiOptionsGetFull();
+
+ const importDetails = {
+ prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported
+ };
for (let i = 0, ii = files.length; i < ii; ++i) {
setProgress(0.0);
@@ -582,25 +593,26 @@ async function onDictionaryImport(e) {
dictImportInfo.textContent = `(${i + 1} of ${ii})`;
}
- const summary = await utilDatabaseImport(files[i], updateProgress, exceptions);
- for (const options of toIterable(await getOptionsArray())) {
+ const {result, errors} = await utilDatabaseImport(files[i], updateProgress, importDetails);
+ for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) {
const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions();
dictionaryOptions.enabled = true;
- options.dictionaries[summary.title] = dictionaryOptions;
- if (summary.sequenced && options.general.mainDictionary === '') {
- options.general.mainDictionary = summary.title;
+ options.dictionaries[result.title] = dictionaryOptions;
+ if (result.sequenced && options.general.mainDictionary === '') {
+ options.general.mainDictionary = result.title;
}
}
await settingsSaveOptions();
- if (exceptions.length > 0) {
- exceptions.push(`Dictionary may not have been imported properly: ${exceptions.length} error${exceptions.length === 1 ? '' : 's'} reported.`);
- dictionaryErrorsShow(exceptions);
+ if (errors.length > 0) {
+ errors.push(...errors);
+ errors.push(`Dictionary may not have been imported properly: ${errors.length} error${errors.length === 1 ? '' : 's'} reported.`);
+ dictionaryErrorsShow(errors);
}
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
onDatabaseUpdated(options);
}
} catch (err) {
@@ -616,3 +628,12 @@ async function onDictionaryImport(e) {
dictProgress.hide();
}
}
+
+
+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();
+}
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index 7456e7a4..56828a15 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,12 +13,17 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-async function getOptionsArray() {
- const optionsFull = await apiOptionsGetFull();
- return optionsFull.profiles.map((profile) => profile.options);
+function getOptionsMutable(optionsContext) {
+ return utilBackend().getOptions(
+ utilBackgroundIsolate(optionsContext)
+ );
+}
+
+function getOptionsFullMutable() {
+ return utilBackend().getFullOptions();
}
async function formRead(options) {
@@ -75,7 +80,6 @@ async function formRead(options) {
options.anki.server = $('#interface-server').val();
options.anki.screenshot.format = $('#screenshot-format').val();
options.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10);
- options.anki.fieldTemplates = $('#field-templates').val();
if (optionsAnkiEnableOld && !ankiErrorShown()) {
options.anki.terms.deck = $('#anki-terms-deck').val();
@@ -140,9 +144,8 @@ async function formWrite(options) {
$('#interface-server').val(options.anki.server);
$('#screenshot-format').val(options.anki.screenshot.format);
$('#screenshot-quality').val(options.anki.screenshot.quality);
- $('#field-templates').val(options.anki.fieldTemplates);
- onAnkiTemplatesValidateCompile();
+ await ankiTemplatesUpdateValue();
await onAnkiOptionsChanged(options);
await onDictionaryOptionsChanged(options);
@@ -161,7 +164,9 @@ function formUpdateVisibility(options) {
if (options.general.debugInfo) {
const temp = utilIsolate(options);
- temp.anki.fieldTemplates = '...';
+ if (typeof temp.anki.fieldTemplates === 'string') {
+ temp.anki.fieldTemplates = '...';
+ }
const text = JSON.stringify(temp, null, 4);
$('#debug').text(text);
}
@@ -169,7 +174,7 @@ function formUpdateVisibility(options) {
async function onFormOptionsChanged() {
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
await formRead(options);
await settingsSaveOptions();
@@ -195,21 +200,10 @@ async function onOptionsUpdate({source}) {
if (source === thisSource) { return; }
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
await formWrite(options);
}
-function onMessage({action, params}, sender, callback) {
- switch (action) {
- case 'optionsUpdate':
- onOptionsUpdate(params);
- break;
- case 'getUrl':
- callback({url: window.location.href});
- break;
- }
-}
-
function showExtensionInformation() {
const node = document.getElementById('extension-info');
@@ -233,7 +227,7 @@ async function onReady() {
storageInfoInitialize();
- chrome.runtime.onMessage.addListener(onMessage);
+ yomichan.on('optionsUpdate', onOptionsUpdate);
}
$(document).ready(() => onReady());
diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js
index 49409968..2b727cbd 100644
--- a/ext/bg/js/settings/popup-preview-frame.js
+++ b/ext/bg/js/settings/popup-preview-frame.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -24,6 +24,7 @@ class SettingsPopupPreview {
this.popupInjectOuterStylesheetOld = Popup.injectOuterStylesheet;
this.popupShown = false;
this.themeChangeTimeout = null;
+ this.textSource = null;
}
static create() {
@@ -46,16 +47,18 @@ class SettingsPopupPreview {
window.apiOptionsGet = (...args) => this.apiOptionsGet(...args);
// Overwrite frontend
- this.frontend = Frontend.create();
- window.yomichan_frontend = this.frontend;
+ const popupHost = new PopupProxyHost();
+ await popupHost.prepare();
+
+ const popup = popupHost.createPopup(null, 0);
+ popup.setChildrenSupported(false);
+
+ this.frontend = new Frontend(popup);
this.frontend.setEnabled = function () {};
this.frontend.searchClear = function () {};
- this.frontend.popup.childrenSupported = false;
- this.frontend.popup.interactive = false;
-
- await this.frontend.isPrepared();
+ await this.frontend.prepare();
// Overwrite popup
Popup.injectOuterStylesheet = (...args) => this.popupInjectOuterStylesheet(...args);
@@ -95,7 +98,7 @@ class SettingsPopupPreview {
onWindowResize() {
if (this.frontend === null) { return; }
- const textSource = this.frontend.textSourceLast;
+ const textSource = this.textSource;
if (textSource === null) { return; }
const elementRect = textSource.getRect();
@@ -105,11 +108,10 @@ class SettingsPopupPreview {
onMessage(e) {
const {action, params} = e.data;
- const handlers = SettingsPopupPreview.messageHandlers;
- if (hasOwn(handlers, action)) {
- const handler = handlers[action];
- handler(this, params);
- }
+ const handler = SettingsPopupPreview._messageHandlers.get(action);
+ if (typeof handler !== 'function') { return; }
+
+ handler(this, params);
}
onThemeDarkCheckboxChanged(node) {
@@ -160,13 +162,14 @@ class SettingsPopupPreview {
const source = new TextSourceRange(range, range.toString(), null);
try {
- await this.frontend.searchSource(source, 'script');
+ await this.frontend.onSearchSource(source, 'script');
} finally {
source.cleanup();
}
- await this.frontend.lastShowPromise;
+ this.textSource = source;
+ await this.frontend.showContentCompleted();
- if (this.frontend.popup.isVisible()) {
+ if (this.frontend.popup.isVisibleSync()) {
this.popupShown = true;
}
@@ -174,11 +177,11 @@ class SettingsPopupPreview {
}
}
-SettingsPopupPreview.messageHandlers = {
- setText: (self, {text}) => self.setText(text),
- setCustomCss: (self, {css}) => self.setCustomCss(css),
- setCustomOuterCss: (self, {css}) => self.setCustomOuterCss(css)
-};
+SettingsPopupPreview._messageHandlers = new Map([
+ ['setText', (self, {text}) => self.setText(text)],
+ ['setCustomCss', (self, {css}) => self.setCustomCss(css)],
+ ['setCustomOuterCss', (self, {css}) => self.setCustomOuterCss(css)]
+]);
SettingsPopupPreview.instance = SettingsPopupPreview.create();
diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js
index d8579eb1..0d20471e 100644
--- a/ext/bg/js/settings/popup-preview.js
+++ b/ext/bg/js/settings/popup-preview.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js
index 8c218e97..c4e68b53 100644
--- a/ext/bg/js/settings/profiles.js
+++ b/ext/bg/js/settings/profiles.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
let currentProfileIndex = 0;
@@ -27,7 +27,7 @@ function getOptionsContext() {
async function profileOptionsSetup() {
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await getOptionsFullMutable();
currentProfileIndex = optionsFull.profileCurrent;
profileOptionsSetupEventListeners();
@@ -120,7 +120,7 @@ async function profileOptionsUpdateTarget(optionsFull) {
profileFormWrite(optionsFull);
const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ const options = await getOptionsMutable(optionsContext);
await formWrite(options);
}
@@ -164,13 +164,13 @@ async function onProfileOptionsChanged(e) {
return;
}
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await getOptionsFullMutable();
await profileFormRead(optionsFull);
await settingsSaveOptions();
}
async function onTargetProfileChanged() {
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await getOptionsFullMutable();
const index = tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length);
if (index === null || currentProfileIndex === index) {
return;
@@ -182,7 +182,7 @@ async function onTargetProfileChanged() {
}
async function onProfileAdd() {
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await getOptionsFullMutable();
const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]);
profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100);
optionsFull.profiles.push(profile);
@@ -210,7 +210,7 @@ async function onProfileRemove(e) {
async function onProfileRemoveConfirm() {
$('#profile-remove-modal').modal('hide');
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await getOptionsFullMutable();
if (optionsFull.profiles.length <= 1) {
return;
}
@@ -234,7 +234,7 @@ function onProfileNameChanged() {
}
async function onProfileMove(offset) {
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await getOptionsFullMutable();
const index = currentProfileIndex + offset;
if (index < 0 || index >= optionsFull.profiles.length) {
return;
@@ -267,7 +267,7 @@ async function onProfileCopy() {
async function onProfileCopyConfirm() {
$('#profile-copy-modal').modal('hide');
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await getOptionsFullMutable();
const index = tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length);
if (index === null || index === currentProfileIndex) {
return;
diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js
index 51ca6855..6c10f665 100644
--- a/ext/bg/js/settings/storage.js
+++ b/ext/bg/js/settings/storage.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js
index 9320477f..eae4e014 100644
--- a/ext/bg/js/templates.js
+++ b/ext/bg/js/templates.js
@@ -143,11 +143,11 @@ templates['kanji.html'] = template({"1":function(container,depth0,helpers,partia
},"33":function(container,depth0,helpers,partials,data) {
return "class=\"source-term\"";
},"35":function(container,depth0,helpers,partials,data) {
- return "class=\"source-term term-button-fade\"";
+ return "class=\"source-term invisible\"";
},"37":function(container,depth0,helpers,partials,data) {
return "class=\"next-term\"";
},"39":function(container,depth0,helpers,partials,data) {
- return "class=\"next-term term-button-fade\"";
+ return "class=\"next-term invisible\"";
},"41":function(container,depth0,helpers,partials,data,blockParams,depths) {
var stack1;
@@ -491,11 +491,11 @@ templates['terms.html'] = template({"1":function(container,depth0,helpers,partia
},"67":function(container,depth0,helpers,partials,data) {
return "class=\"source-term\"";
},"69":function(container,depth0,helpers,partials,data) {
- return "class=\"source-term term-button-fade\"";
+ return "class=\"source-term invisible\"";
},"71":function(container,depth0,helpers,partials,data) {
return "class=\"next-term\"";
},"73":function(container,depth0,helpers,partials,data) {
- return "class=\"next-term term-button-fade\"";
+ return "class=\"next-term invisible\"";
},"75":function(container,depth0,helpers,partials,data,blockParams,depths) {
var stack1;
diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js
index 202014c9..7473c6ad 100644
--- a/ext/bg/js/translator.js
+++ b/ext/bg/js/translator.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -230,7 +230,7 @@ class Translator {
const titles = Object.keys(dictionaries);
const deinflections = (
details.wildcard ?
- await this.findTermWildcard(text, titles) :
+ await this.findTermWildcard(text, titles, details.wildcard) :
await this.findTermDeinflections(text, titles)
);
@@ -268,8 +268,8 @@ class Translator {
return [definitions, length];
}
- async findTermWildcard(text, titles) {
- const definitions = await this.database.findTermsBulk([text], titles, true);
+ async findTermWildcard(text, titles, wildcard) {
+ const definitions = await this.database.findTermsBulk([text], titles, wildcard);
if (definitions.length === 0) {
return [];
}
@@ -308,7 +308,7 @@ class Translator {
deinflectionArray.push(deinflection);
}
- const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, titles, false);
+ const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, titles, null);
for (const definition of definitions) {
const definitionRules = Deinflector.rulesToRuleFlags(definition.rules);
diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js
index 3dd5fd55..333e814b 100644
--- a/ext/bg/js/util.js
+++ b/ext/bg/js/util.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,11 +13,40 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-function utilIsolate(data) {
- return JSON.parse(JSON.stringify(data));
+function utilIsolate(value) {
+ if (value === null) { return null; }
+
+ switch (typeof value) {
+ case 'boolean':
+ case 'number':
+ case 'string':
+ case 'bigint':
+ case 'symbol':
+ return value;
+ }
+
+ const stringValue = JSON.stringify(value);
+ return typeof stringValue === 'string' ? JSON.parse(stringValue) : null;
+}
+
+function utilFunctionIsolate(func) {
+ return function (...args) {
+ try {
+ args = args.map((v) => utilIsolate(v));
+ return func.call(this, ...args);
+ } catch (e) {
+ try {
+ String(func);
+ } catch (e2) {
+ // Dead object
+ return;
+ }
+ throw e;
+ }
+ };
}
function utilBackgroundIsolate(data) {
@@ -25,6 +54,11 @@ function utilBackgroundIsolate(data) {
return backgroundPage.utilIsolate(data);
}
+function utilBackgroundFunctionIsolate(func) {
+ const backgroundPage = chrome.extension.getBackgroundPage();
+ return backgroundPage.utilFunctionIsolate(func);
+}
+
function utilSetEqual(setA, setB) {
if (setA.size !== setB.size) {
return false;
@@ -54,6 +88,8 @@ function utilSetDifference(setA, setB) {
function utilStringHashCode(string) {
let hashCode = 0;
+ if (typeof string !== 'string') { return hashCode; }
+
for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) {
hashCode = ((hashCode << 5) - hashCode) + charCode;
hashCode |= 0;
@@ -63,44 +99,52 @@ function utilStringHashCode(string) {
}
function utilBackend() {
- return chrome.extension.getBackgroundPage().yomichan_backend;
+ return chrome.extension.getBackgroundPage().yomichanBackend;
}
-function utilAnkiGetModelNames() {
- return utilBackend().anki.getModelNames();
+async function utilAnkiGetModelNames() {
+ return utilIsolate(await utilBackend().anki.getModelNames());
}
-function utilAnkiGetDeckNames() {
- return utilBackend().anki.getDeckNames();
+async function utilAnkiGetDeckNames() {
+ return utilIsolate(await utilBackend().anki.getDeckNames());
}
-function utilDatabaseGetDictionaryInfo() {
- return utilBackend().translator.database.getDictionaryInfo();
+async function utilDatabaseGetDictionaryInfo() {
+ return utilIsolate(await utilBackend().translator.database.getDictionaryInfo());
}
-function utilDatabaseGetDictionaryCounts(dictionaryNames, getTotal) {
- return utilBackend().translator.database.getDictionaryCounts(dictionaryNames, getTotal);
+async function utilDatabaseGetDictionaryCounts(dictionaryNames, getTotal) {
+ return utilIsolate(await utilBackend().translator.database.getDictionaryCounts(
+ utilBackgroundIsolate(dictionaryNames),
+ utilBackgroundIsolate(getTotal)
+ ));
}
-function utilAnkiGetModelFieldNames(modelName) {
- return utilBackend().anki.getModelFieldNames(modelName);
+async function utilAnkiGetModelFieldNames(modelName) {
+ return utilIsolate(await utilBackend().anki.getModelFieldNames(
+ utilBackgroundIsolate(modelName)
+ ));
}
-function utilDatabasePurge() {
- return utilBackend().translator.purgeDatabase();
+async function utilDatabasePurge() {
+ return utilIsolate(await utilBackend().translator.purgeDatabase());
}
-function utilDatabaseDeleteDictionary(dictionaryName, onProgress) {
- return utilBackend().translator.database.deleteDictionary(dictionaryName, onProgress);
+async function utilDatabaseDeleteDictionary(dictionaryName, onProgress) {
+ return utilIsolate(await utilBackend().translator.database.deleteDictionary(
+ utilBackgroundIsolate(dictionaryName),
+ utilBackgroundFunctionIsolate(onProgress)
+ ));
}
-async function utilDatabaseImport(data, progress, exceptions) {
- // Edge cannot read data on the background page due to the File object
- // being created from a different window. Read on the same page instead.
- if (EXTENSION_IS_BROWSER_EDGE) {
- data = await utilReadFile(data);
- }
- return utilBackend().translator.database.importDictionary(data, progress, exceptions);
+async function utilDatabaseImport(data, onProgress, details) {
+ data = await utilReadFile(data);
+ return utilIsolate(await utilBackend().translator.database.importDictionary(
+ utilBackgroundIsolate(data),
+ utilBackgroundFunctionIsolate(onProgress),
+ utilBackgroundIsolate(details)
+ ));
}
function utilReadFile(file) {
@@ -111,3 +155,12 @@ function utilReadFile(file) {
reader.readAsBinaryString(file);
});
}
+
+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 082239d7..c1e606d7 100644
--- a/ext/bg/legal.html
+++ b/ext/bg/legal.html
@@ -17,7 +17,7 @@
<div class="container">
<h3>Yomichan License</h3>
<pre>
-Copyright (C) 2016-2019 Alex Yatskov
+Copyright (C) 2016-2020 Alex Yatskov
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
@@ -30,7 +30,7 @@ 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 &lt;http://www.gnu.org/licenses/&gt;.
+along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;.
</pre>
<h3>EDRDG License</h3>
<pre>
diff --git a/ext/bg/search.html b/ext/bg/search.html
index 58bb9ba8..409243dd 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -62,11 +62,11 @@
<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/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
<script src="/bg/js/templates.js"></script>
- <script src="/fg/js/api.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/source.js"></script>
<script src="/mixed/js/audio.js"></script>
@@ -74,6 +74,7 @@
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/japanese.js"></script>
<script src="/mixed/js/scroll.js"></script>
+ <script src="/mixed/js/text-scanner.js"></script>
<script src="/bg/js/search-query-parser.js"></script>
<script src="/bg/js/search.js"></script>
diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html
index 339467d4..f33ecedf 100644
--- a/ext/bg/settings-popup-preview.html
+++ b/ext/bg/settings-popup-preview.html
@@ -119,13 +119,13 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/api.js"></script>
+ <script src="/mixed/js/text-scanner.js"></script>
- <script src="/fg/js/api.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/frontend-api-receiver.js"></script>
<script src="/fg/js/popup.js"></script>
<script src="/fg/js/source.js"></script>
- <script src="/fg/js/util.js"></script>
<script src="/fg/js/popup-proxy-host.js"></script>
<script src="/fg/js/frontend.js"></script>
<script src="/bg/js/settings/popup-preview-frame.js"></script>
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 3c5494b8..4c973674 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -299,7 +299,7 @@
<input type="number" min="0" max="100" id="audio-playback-volume" class="form-control">
</div>
- <div class="form-group" style="display: none;" id="text-to-speech-voice-container">
+ <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>
@@ -365,12 +365,12 @@
<div class="form-group options-advanced">
<label for="scan-delay">Scan delay <span class="label-light">(in milliseconds)</span></label>
- <input type="number" min="1" id="scan-delay" class="form-control">
+ <input type="number" min="0" id="scan-delay" class="form-control">
</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" id="scan-length" class="form-control">
+ <input type="number" min="1" step="1" id="scan-length" class="form-control">
</div>
<div class="form-group">
@@ -406,7 +406,7 @@
<div class="form-group">
<label for="popup-nesting-max-depth">Maximum number of additional popups</label>
- <input type="number" min="0" id="popup-nesting-max-depth" class="form-control">
+ <input type="number" min="0" step="1" id="popup-nesting-max-depth" class="form-control">
</div>
</div>
@@ -491,6 +491,18 @@
<div hidden><input type="file" id="dict-file" accept=".zip,application/zip" multiple></div>
</div>
+ <div>
+ <h3>Dictionary Options</h3>
+ </div>
+
+ <div class="checkbox">
+ <label><input type="checkbox" id="database-enable-prefix-wildcard-searches"> 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.
+ </p>
+ </div>
+
<div class="modal fade" tabindex="-1" role="dialog" id="dict-purge-modal">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@@ -538,6 +550,9 @@
<div class="checkbox options-advanced">
<label><input type="checkbox" class="dict-allow-secondary-searches"> Allow secondary searches</label>
</div>
+ <div class="checkbox dict-prefix-wildcard-searches-supported-container">
+ <label><input type="checkbox" class="dict-prefix-wildcard-searches-supported" disabled> Prefix wildcard searches supported</label>
+ </div>
<div class="form-group options-advanced">
<label class="dict-result-priority-label">Result priority</label>
<input type="number" class="form-control dict-priority">
@@ -659,7 +674,7 @@
<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" id="sentence-detection-extent" class="form-control">
+ <input type="number" min="1" step="1" id="sentence-detection-extent" class="form-control">
</div>
<div class="form-group options-advanced">
@@ -739,7 +754,9 @@
engine. Advanced users can modify these templates for ultimate control of what information gets included in
their Anki cards. If you encounter problems with your changes, you can always reset to the default template settings.
</p>
- <textarea autocomplete="off" spellcheck="false" wrap="soft" class="form-control" rows="10" id="field-templates"></textarea>
+ <div class="ignore-form-changes">
+ <textarea autocomplete="off" spellcheck="false" wrap="soft" class="form-control" rows="10" id="field-templates"></textarea>
+ </div>
<div>
<button class="btn btn-danger" id="field-templates-reset">Reset Templates</button>
</div>
@@ -836,6 +853,102 @@
</ul>
</div>
+
+ <div>
+ <h3>Backup</h3>
+
+ <p class="help-block">
+ Yomichan can import and export settings files which can be used to restore settings,
+ share settings across devices, or help to debug problems.
+ These files will only contain settings and will not contain dictionaries.
+ Dictionaries must be imported separately.
+ </p>
+
+ <div>
+ <button class="btn btn-default" id="settings-export">Export Settings</button>
+ <button class="btn btn-default" id="settings-import">Import Settings</button>
+ <button class="btn btn-danger" id="settings-reset">Reset Default Settings</button>
+ </div>
+
+ <div hidden><input type="file" id="settings-import-file" accept=".json,application/json"></div>
+
+ <div class="modal fade" tabindex="-1" role="dialog" id="settings-import-error-modal">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 class="modal-title">Import Error</h4>
+ </div>
+ <div class="modal-body">
+ <p>
+ An error occurred while trying to import the settings file:
+ </p>
+ <p class="text-danger" id="settings-import-error-modal-message"></p>
+ <p>
+ Additional info can be found in the developer console.
+ </p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal fade" tabindex="-1" role="dialog" id="settings-import-warning-modal">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 class="modal-title">Import Security Warning</h4>
+ </div>
+ <div class="modal-body">
+ <p>
+ Settings file contains settings which may pose a security risk.
+ Only import settings from sources you trust.
+ </p>
+ <ul class="text-danger" id="settings-import-warning-modal-message"></ul>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-danger settings-import-warning-modal-import-button">Import</button>
+ <button type="button" class="btn btn-primary settings-import-warning-modal-import-button" data-import-sanitize="true">Sanitize and Import</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal fade" tabindex="-1" role="dialog" id="settings-reset-modal">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 class="modal-title">Settings Reset</h4>
+ </div>
+ <div class="modal-body">
+ <p class="text-danger">
+ You are about to reset all Yomichan settings back to their default values.
+ This will delete all custom profiles you may have created.
+ <strong>This action cannot be undone.</strong>
+ </p>
+ <p>
+ Consider making a backup using the "Export Settings" button before resetting
+ if you want to be able to revert.
+ </p>
+ <p>
+ Dictionary data will not be deleted, but any installed dictionaries
+ will need to be re-enabled.
+ </p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-danger" id="settings-reset-modal-confirm">Reset All Settings</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
<div>
<h3>Support Development</h3>
@@ -866,13 +979,11 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/api.js"></script>
<script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/anki.js"></script>
- <script src="/bg/js/api.js"></script>
- <script src="/bg/js/audio-ui.js"></script>
<script src="/bg/js/conditions.js"></script>
- <script src="/bg/js/conditions-ui.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
<script src="/bg/js/options.js"></script>
@@ -885,6 +996,9 @@
<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/conditions-ui.js"></script>
<script src="/bg/js/settings/dictionaries.js"></script>
<script src="/bg/js/settings/popup-preview.js"></script>
<script src="/bg/js/settings/profiles.js"></script>
diff --git a/ext/fg/css/client.css b/ext/fg/css/client.css
index 633c88ef..b9c59da7 100644
--- a/ext/fg/css/client.css
+++ b/ext/fg/css/client.css
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/fg/float.html b/ext/fg/float.html
index 67ee50b4..886e5e8b 100644
--- a/ext/fg/float.html
+++ b/ext/fg/float.html
@@ -33,8 +33,8 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/api.js"></script>
- <script src="/fg/js/api.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/source.js"></script>
<script src="/mixed/js/audio.js"></script>
diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js
index 10dea7df..e068e3ba 100644
--- a/ext/fg/js/document.js
+++ b/ext/fg/js/document.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -97,15 +97,16 @@ function docImposterCreate(element, isTextarea) {
function docElementsFromPoint(x, y, all) {
if (all) {
- return document.elementsFromPoint(x, y);
+ // document.elementsFromPoint can return duplicates which must be removed.
+ const elements = document.elementsFromPoint(x, y);
+ return elements.filter((e, i) => elements.indexOf(e) === i);
}
const e = document.elementFromPoint(x, y);
return e !== null ? [e] : [];
}
-function docRangeFromPoint(x, y, options) {
- const deepDomScan = options.scanning.deepDomScan;
+function docRangeFromPoint(x, y, deepDomScan) {
const elements = docElementsFromPoint(x, y, deepDomScan);
let imposter = null;
let imposterContainer = null;
@@ -319,7 +320,7 @@ function disableTransparentElement(elements, i, modifications) {
if (isElementTransparent(element)) {
const style = element.hasAttribute('style') ? element.getAttribute('style') : null;
modifications.push({element, style});
- element.style.pointerEvents = 'none';
+ element.style.setProperty('pointer-events', 'none', 'important');
return i;
}
}
diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js
index ae54be00..513d246b 100644
--- a/ext/fg/js/float.js
+++ b/ext/fg/js/float.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -27,17 +27,24 @@ class DisplayFloat extends Display {
url: window.location.href
};
+ this._orphaned = false;
+
+ yomichan.on('orphaned', () => this.onOrphaned());
window.addEventListener('message', (e) => this.onMessage(e), false);
}
onError(error) {
- if (window.yomichan_orphaned) {
+ if (this._orphaned) {
this.setContentOrphaned();
} else {
logError(error, true);
}
}
+ onOrphaned() {
+ this._orphaned = true;
+ }
+
onSearchClear() {
window.parent.postMessage('popupClose', '*');
}
@@ -48,24 +55,22 @@ class DisplayFloat extends Display {
onMessage(e) {
const {action, params} = e.data;
- const handlers = DisplayFloat.messageHandlers;
- if (hasOwn(handlers, action)) {
- const handler = handlers[action];
- handler(this, params);
- }
+ const handler = DisplayFloat._messageHandlers.get(action);
+ if (typeof handler !== 'function') { return; }
+
+ handler(this, params);
}
onKeyDown(e) {
const key = Display.getKeyFromEvent(e);
- const handlers = DisplayFloat.onKeyDownHandlers;
- if (hasOwn(handlers, key)) {
- const handler = handlers[key];
+ const handler = DisplayFloat._onKeyDownHandlers.get(key);
+ if (typeof handler === 'function') {
if (handler(this, e)) {
e.preventDefault();
- return;
+ return true;
}
}
- super.onKeyDown(e);
+ return super.onKeyDown(e);
}
getOptionsContext() {
@@ -97,21 +102,21 @@ class DisplayFloat extends Display {
}
}
-DisplayFloat.onKeyDownHandlers = {
- 'C': (self, e) => {
+DisplayFloat._onKeyDownHandlers = new Map([
+ ['C', (self, e) => {
if (e.ctrlKey && !window.getSelection().toString()) {
self.onSelectionCopy();
return true;
}
return false;
- }
-};
+ }]
+]);
-DisplayFloat.messageHandlers = {
- setContent: (self, {type, details}) => self.setContent(type, details),
- clearAutoPlayTimer: (self) => self.clearAutoPlayTimer(),
- setCustomCss: (self, {css}) => self.setCustomCss(css),
- initialize: (self, {options, popupInfo, url, childrenSupported}) => self.initialize(options, popupInfo, url, childrenSupported)
-};
+DisplayFloat._messageHandlers = new Map([
+ ['setContent', (self, {type, details}) => self.setContent(type, details)],
+ ['clearAutoPlayTimer', (self) => self.clearAutoPlayTimer()],
+ ['setCustomCss', (self, {css}) => self.setCustomCss(css)],
+ ['initialize', (self, {options, popupInfo, url, childrenSupported}) => self.initialize(options, popupInfo, url, childrenSupported)]
+]);
-window.yomichan_display = new DisplayFloat();
+DisplayFloat.instance = new DisplayFloat();
diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js
index 7d38ddd5..642d96df 100644
--- a/ext/fg/js/frontend-api-receiver.js
+++ b/ext/fg/js/frontend-api-receiver.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,14 +13,14 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
class FrontendApiReceiver {
- constructor(source='', handlers={}) {
- this.source = source;
- this.handlers = handlers;
+ constructor(source='', handlers=new Map()) {
+ this._source = source;
+ this._handlers = handlers;
chrome.runtime.onConnect.addListener(this.onConnect.bind(this));
}
@@ -32,16 +32,13 @@ class FrontendApiReceiver {
}
onMessage(port, {id, action, params, target, senderId}) {
- if (
- target !== this.source ||
- !hasOwn(this.handlers, action)
- ) {
- return;
- }
+ if (target !== this._source) { return; }
+
+ const handler = this._handlers.get(action);
+ if (typeof handler !== 'function') { return; }
this.sendAck(port, id, senderId);
- const handler = this.handlers[action];
handler(params).then(
(result) => {
this.sendResult(port, id, senderId, {result});
diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js
index af998a8f..93c2e593 100644
--- a/ext/fg/js/frontend-api-sender.js
+++ b/ext/fg/js/frontend-api-sender.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -22,7 +22,7 @@ class FrontendApiSender {
this.senderId = FrontendApiSender.generateId(16);
this.ackTimeout = 3000; // 3 seconds
this.responseTimeout = 10000; // 10 seconds
- this.callbacks = {};
+ this.callbacks = new Map();
this.disconnected = false;
this.nextId = 0;
@@ -43,7 +43,7 @@ class FrontendApiSender {
return new Promise((resolve, reject) => {
const info = {id, resolve, reject, ack: false, timer: null};
- this.callbacks[id] = info;
+ this.callbacks.set(id, info);
info.timer = setTimeout(() => this.onError(id, 'Timeout (ack)'), this.ackTimeout);
this.port.postMessage({id, action, params, target, senderId: this.senderId});
@@ -71,19 +71,18 @@ class FrontendApiSender {
onDisconnect() {
this.disconnected = true;
- const ids = Object.keys(this.callbacks);
- for (const id of ids) {
+ for (const id of this.callbacks.keys()) {
this.onError(id, 'Disconnected');
}
}
onAck(id) {
- if (!hasOwn(this.callbacks, id)) {
+ const info = this.callbacks.get(id);
+ if (typeof info === 'undefined') {
console.warn(`ID ${id} not found for ack`);
return;
}
- const info = this.callbacks[id];
if (info.ack) {
console.warn(`Request ${id} already ack'd`);
return;
@@ -95,18 +94,18 @@ class FrontendApiSender {
}
onResult(id, data) {
- if (!hasOwn(this.callbacks, id)) {
+ const info = this.callbacks.get(id);
+ if (typeof info === 'undefined') {
console.warn(`ID ${id} not found`);
return;
}
- const info = this.callbacks[id];
if (!info.ack) {
console.warn(`Request ${id} not ack'd`);
return;
}
- delete this.callbacks[id];
+ this.callbacks.delete(id);
clearTimeout(info.timer);
info.timer = null;
@@ -118,9 +117,9 @@ class FrontendApiSender {
}
onError(id, reason) {
- if (!hasOwn(this.callbacks, id)) { return; }
- const info = this.callbacks[id];
- delete this.callbacks[id];
+ 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-initialize.js b/ext/fg/js/frontend-initialize.js
index 37a82faa..9c923fea 100644
--- a/ext/fg/js/frontend-initialize.js
+++ b/ext/fg/js/frontend-initialize.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,8 +13,23 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-window.yomichan_frontend = Frontend.create();
+async function main() {
+ const data = window.frontendInitializationData || {};
+ const {id, depth=0, parentFrameId, ignoreNodes, url, proxy=false} = data;
+
+ let popupHost = null;
+ if (!proxy) {
+ popupHost = new PopupProxyHost();
+ await popupHost.prepare();
+ }
+
+ const popup = proxy ? new PopupProxy(depth + 1, id, parentFrameId, url) : popupHost.createPopup(null, depth);
+ const frontend = new Frontend(popup, ignoreNodes);
+ await frontend.prepare();
+}
+
+main();
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
index 9a1d507b..034d9075 100644
--- a/ext/fg/js/frontend.js
+++ b/ext/fg/js/frontend.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,221 +13,47 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-class Frontend {
+class Frontend extends TextScanner {
constructor(popup, ignoreNodes) {
+ super(
+ window,
+ ignoreNodes,
+ popup.isProxy() ? [] : [popup.getContainer()],
+ [(x, y) => this.popup.containsPoint(x, y)]
+ );
+
this.popup = popup;
- this.popupTimerPromise = null;
- this.textSourceCurrent = null;
- this.pendingLookup = false;
this.options = null;
- this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null);
this.optionsContext = {
depth: popup.depth,
url: popup.url
};
- this.primaryTouchIdentifier = null;
- this.preventNextContextMenu = false;
- this.preventNextMouseDown = false;
- this.preventNextClick = false;
- this.preventScroll = false;
-
- this.enabled = false;
- this.eventListeners = [];
-
- this.isPreparedPromiseResolve = null;
- this.isPreparedPromise = new Promise((resolve) => { this.isPreparedPromiseResolve = resolve; });
-
- this.lastShowPromise = Promise.resolve();
- }
-
- static create() {
- const data = window.frontendInitializationData || {};
- const {id, depth=0, parentFrameId, ignoreNodes, url, proxy=false} = data;
-
- const popup = proxy ? new PopupProxy(depth + 1, id, parentFrameId, url) : PopupProxyHost.instance.createPopup(null, depth);
- const frontend = new Frontend(popup, ignoreNodes);
- frontend.prepare();
- return frontend;
+ this._orphaned = true;
+ this._lastShowPromise = Promise.resolve();
}
async prepare() {
try {
await this.updateOptions();
+ yomichan.on('orphaned', () => this.onOrphaned());
+ yomichan.on('optionsUpdate', () => this.updateOptions());
chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
- this.isPreparedPromiseResolve();
} catch (e) {
this.onError(e);
}
}
- isPrepared() {
- return this.isPreparedPromise;
- }
-
- onMouseOver(e) {
- if (e.target === this.popup.container) {
- this.popupTimerClear();
- }
- }
-
- onMouseMove(e) {
- this.popupTimerClear();
-
- if (this.pendingLookup || DOM.isMouseButtonDown(e, 'primary')) {
- return;
- }
-
- const scanningOptions = this.options.scanning;
- const scanningModifier = scanningOptions.modifier;
- if (!(
- Frontend.isScanningModifierPressed(scanningModifier, e) ||
- (scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary'))
- )) {
- return;
- }
-
- const search = async () => {
- if (scanningModifier === 'none') {
- if (!await this.popupTimerWait()) {
- // Aborted
- return;
- }
- }
-
- await this.searchAt(e.clientX, e.clientY, 'mouse');
- };
-
- search();
- }
-
- onMouseDown(e) {
- if (this.preventNextMouseDown) {
- this.preventNextMouseDown = false;
- this.preventNextClick = true;
- e.preventDefault();
- e.stopPropagation();
- return false;
- }
-
- if (e.button === 0) {
- this.popupTimerClear();
- this.searchClear(true);
- }
- }
-
- onMouseOut() {
- this.popupTimerClear();
- }
-
- onClick(e) {
- if (this.preventNextClick) {
- this.preventNextClick = false;
- e.preventDefault();
- e.stopPropagation();
- return false;
- }
- }
-
- onAuxClick() {
- this.preventNextContextMenu = false;
- }
-
- onContextMenu(e) {
- if (this.preventNextContextMenu) {
- this.preventNextContextMenu = false;
- e.preventDefault();
- e.stopPropagation();
- return false;
- }
- }
-
- onTouchStart(e) {
- if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) {
- return;
- }
-
- this.preventScroll = false;
- this.preventNextContextMenu = false;
- this.preventNextMouseDown = false;
- this.preventNextClick = false;
-
- const primaryTouch = e.changedTouches[0];
- if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, window.getSelection())) {
- return;
- }
-
- this.primaryTouchIdentifier = primaryTouch.identifier;
-
- if (this.pendingLookup) {
- return;
- }
-
- const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null;
-
- this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart')
- .then(() => {
- if (
- this.textSourceCurrent === null ||
- this.textSourceCurrent.equals(textSourceCurrentPrevious)
- ) {
- return;
- }
-
- this.preventScroll = true;
- this.preventNextContextMenu = true;
- this.preventNextMouseDown = true;
- });
- }
-
- onTouchEnd(e) {
- if (
- this.primaryTouchIdentifier === null ||
- this.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0
- ) {
- return;
- }
-
- this.primaryTouchIdentifier = null;
- this.preventScroll = false;
- this.preventNextClick = false;
- // Don't revert context menu and mouse down prevention,
- // since these events can occur after the touch has ended.
- // this.preventNextContextMenu = false;
- // this.preventNextMouseDown = false;
- }
-
- onTouchCancel(e) {
- this.onTouchEnd(e);
- }
-
- onTouchMove(e) {
- if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) {
- return;
- }
-
- const touches = e.changedTouches;
- const index = this.getIndexOfTouch(touches, this.primaryTouchIdentifier);
- if (index < 0) {
- return;
- }
-
- const primaryTouch = touches[index];
- this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove');
-
- e.preventDefault(); // Disable scroll
- }
-
async onResize() {
- if (this.textSourceCurrent !== null && await this.popup.isVisibleAsync()) {
- const textSource = this.textSourceCurrent;
- this.lastShowPromise = this.popup.showContent(
+ const textSource = this.textSourceCurrent;
+ if (textSource !== null && await this.popup.isVisible()) {
+ this._lastShowPromise = this.popup.showContent(
textSource.getRect(),
textSource.getWritingMode()
);
@@ -236,128 +62,43 @@ class Frontend {
onWindowMessage(e) {
const action = e.data;
- const handlers = Frontend.windowMessageHandlers;
- if (hasOwn(handlers, action)) {
- const handler = handlers[action];
- handler(this);
- }
- }
-
- onRuntimeMessage({action, params}, sender, callback) {
- const handlers = Frontend.runtimeMessageHandlers;
- if (hasOwn(handlers, action)) {
- const handler = handlers[action];
- const result = handler(this, params);
- callback(result);
- }
- }
+ const handler = Frontend._windowMessageHandlers.get(action);
+ if (typeof handler !== 'function') { return false; }
- onError(error) {
- logError(error, false);
+ handler(this);
}
- setEnabled(enabled) {
- if (enabled) {
- if (!this.enabled) {
- this.hookEvents();
- this.enabled = true;
- }
- } else {
- if (this.enabled) {
- this.clearEventListeners();
- this.enabled = false;
- }
- this.searchClear(false);
- }
- }
+ onRuntimeMessage({action, params}, sender, callback) {
+ const handler = Frontend._runtimeMessageHandlers.get(action);
+ if (typeof handler !== 'function') { return false; }
- hookEvents() {
- this.addEventListener(window, 'message', this.onWindowMessage.bind(this));
- this.addEventListener(window, 'mousedown', this.onMouseDown.bind(this));
- this.addEventListener(window, 'mousemove', this.onMouseMove.bind(this));
- this.addEventListener(window, 'mouseover', this.onMouseOver.bind(this));
- this.addEventListener(window, 'mouseout', this.onMouseOut.bind(this));
- this.addEventListener(window, 'resize', this.onResize.bind(this));
-
- if (this.options.scanning.touchInputEnabled) {
- this.addEventListener(window, 'click', this.onClick.bind(this));
- this.addEventListener(window, 'auxclick', this.onAuxClick.bind(this));
- this.addEventListener(window, 'touchstart', this.onTouchStart.bind(this));
- this.addEventListener(window, 'touchend', this.onTouchEnd.bind(this));
- this.addEventListener(window, 'touchcancel', this.onTouchCancel.bind(this));
- this.addEventListener(window, 'touchmove', this.onTouchMove.bind(this), {passive: false});
- this.addEventListener(window, 'contextmenu', this.onContextMenu.bind(this));
- }
+ const result = handler(this, params, sender);
+ callback(result);
+ return false;
}
- addEventListener(node, type, listener, options) {
- node.addEventListener(type, listener, options);
- this.eventListeners.push([node, type, listener, options]);
+ onOrphaned() {
+ this._orphaned = true;
}
- clearEventListeners() {
- for (const [node, type, listener, options] of this.eventListeners) {
- node.removeEventListener(type, listener, options);
- }
- this.eventListeners = [];
+ getMouseEventListeners() {
+ return [
+ ...super.getMouseEventListeners(),
+ [window, 'message', this.onWindowMessage.bind(this)],
+ [window, 'resize', this.onResize.bind(this)]
+ ];
}
async updateOptions() {
this.options = await apiOptionsGet(this.getOptionsContext());
- this.setEnabled(this.options.general.enable);
await this.popup.setOptions(this.options);
+ this.setEnabled(this.options.general.enable);
}
- async popupTimerWait() {
- const delay = this.options.scanning.delay;
- const promise = promiseTimeout(delay, true);
- this.popupTimerPromise = promise;
- try {
- return await promise;
- } finally {
- if (this.popupTimerPromise === promise) {
- this.popupTimerPromise = null;
- }
- }
- }
-
- popupTimerClear() {
- if (this.popupTimerPromise !== null) {
- this.popupTimerPromise.resolve(false);
- this.popupTimerPromise = null;
- }
- }
-
- async searchAt(x, y, cause) {
- try {
- this.popupTimerClear();
-
- if (this.pendingLookup || await this.popup.containsPoint(x, y)) {
- return;
- }
-
- const textSource = docRangeFromPoint(x, y, this.options);
- if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) {
- return;
- }
-
- try {
- await this.searchSource(textSource, cause);
- } finally {
- if (textSource !== null) {
- textSource.cleanup();
- }
- }
- } catch (e) {
- this.onError(e);
- }
- }
-
- async searchSource(textSource, cause) {
+ async onSearchSource(textSource, cause) {
let results = null;
try {
- this.pendingLookup = true;
if (textSource !== null) {
results = (
await this.findTerms(textSource) ||
@@ -369,9 +110,9 @@ class Frontend {
}
}
} catch (e) {
- if (window.yomichan_orphaned) {
+ if (this._orphaned) {
if (textSource !== null && this.options.scanning.modifier !== 'none') {
- this.lastShowPromise = this.popup.showContent(
+ this._lastShowPromise = this.popup.showContent(
textSource.getRect(),
textSource.getWritingMode(),
'orphaned'
@@ -382,10 +123,8 @@ class Frontend {
}
} finally {
if (results === null && this.options.scanning.autoHideResults) {
- this.searchClear(true);
+ this.onSearchClear(true);
}
-
- this.pendingLookup = false;
}
return results;
@@ -394,17 +133,16 @@ class Frontend {
showContent(textSource, focus, definitions, type) {
const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
const url = window.location.href;
- this.lastShowPromise = this.popup.showContent(
+ this._lastShowPromise = this.popup.showContent(
textSource.getRect(),
textSource.getWritingMode(),
type,
{definitions, context: {sentence, url, focus, disableHistory: true}}
);
+ }
- this.textSourceCurrent = textSource;
- if (this.options.scanning.selectText) {
- textSource.select();
- }
+ showContentCompleted() {
+ return this._lastShowPromise;
}
async findTerms(textSource) {
@@ -433,82 +171,23 @@ class Frontend {
return {definitions, type: 'kanji'};
}
- searchClear(changeFocus) {
+ onSearchClear(changeFocus) {
this.popup.hide(changeFocus);
this.popup.clearAutoPlayTimer();
-
- if (this.textSourceCurrent !== null) {
- if (this.options.scanning.selectText) {
- this.textSourceCurrent.deselect();
- }
-
- this.textSourceCurrent = null;
- }
- }
-
- getIndexOfTouch(touchList, identifier) {
- for (const i in touchList) {
- const t = touchList[i];
- if (t.identifier === identifier) {
- return i;
- }
- }
- return -1;
- }
-
- setTextSourceScanLength(textSource, length) {
- textSource.setEndOffset(length);
- if (this.ignoreNodes === null || !textSource.range) {
- return;
- }
-
- length = textSource.text().length;
- while (textSource.range && length > 0) {
- const nodes = TextSourceRange.getNodesInRange(textSource.range);
- if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) {
- break;
- }
- --length;
- textSource.setEndOffset(length);
- }
+ super.onSearchClear(changeFocus);
}
getOptionsContext() {
this.optionsContext.url = this.popup.url;
return this.optionsContext;
}
-
- static isScanningModifierPressed(scanningModifier, mouseEvent) {
- switch (scanningModifier) {
- case 'alt': return mouseEvent.altKey;
- case 'ctrl': return mouseEvent.ctrlKey;
- case 'shift': return mouseEvent.shiftKey;
- case 'none': return true;
- default: return false;
- }
- }
}
-Frontend.windowMessageHandlers = {
- popupClose: (self) => {
- self.searchClear(true);
- },
-
- selectionCopy: () => {
- document.execCommand('copy');
- }
-};
-
-Frontend.runtimeMessageHandlers = {
- optionsUpdate: (self) => {
- self.updateOptions();
- },
+Frontend._windowMessageHandlers = new Map([
+ ['popupClose', (self) => self.onSearchClear(true)],
+ ['selectionCopy', () => document.execCommand('copy')]
+]);
- popupSetVisibleOverride: (self, {visible}) => {
- self.popup.setVisibleOverride(visible);
- },
-
- getUrl: () => {
- return {url: window.location.href};
- }
-};
+Frontend._runtimeMessageHandlers = new Map([
+ ['popupSetVisibleOverride', (self, {visible}) => { self.popup.setVisibleOverride(visible); }]
+]);
diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js
index 31cb1cda..bacf3b93 100644
--- a/ext/fg/js/popup-nested.js
+++ b/ext/fg/js/popup-nested.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -41,6 +41,7 @@ async function popupNestedInitialize(id, depth, parentFrameId, url) {
window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes, url, proxy: true};
const scriptSrcs = [
+ '/mixed/js/text-scanner.js',
'/fg/js/frontend-api-sender.js',
'/fg/js/popup.js',
'/fg/js/popup-proxy.js',
diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js
index b2f18b97..c4f0c6ff 100644
--- a/ext/fg/js/popup-proxy-host.js
+++ b/ext/fg/js/popup-proxy-host.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,126 +13,127 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
class PopupProxyHost {
constructor() {
- this.popups = {};
- this.nextId = 0;
- this.apiReceiver = null;
- this.frameIdPromise = null;
+ this._popups = new Map();
+ this._nextId = 0;
+ this._apiReceiver = null;
+ this._frameIdPromise = null;
}
- static create() {
- const popupProxyHost = new PopupProxyHost();
- popupProxyHost.prepare();
- return popupProxyHost;
- }
+ // Public functions
async prepare() {
- this.frameIdPromise = apiFrameInformationGet();
- const {frameId} = await this.frameIdPromise;
+ this._frameIdPromise = apiFrameInformationGet();
+ const {frameId} = await this._frameIdPromise;
if (typeof frameId !== 'number') { return; }
- this.apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, {
- createNestedPopup: ({parentId}) => this.createNestedPopup(parentId),
- setOptions: ({id, options}) => this.setOptions(id, options),
- hide: ({id, changeFocus}) => this.hide(id, changeFocus),
- isVisibleAsync: ({id}) => this.isVisibleAsync(id),
- setVisibleOverride: ({id, visible}) => this.setVisibleOverride(id, visible),
- containsPoint: ({id, x, y}) => this.containsPoint(id, x, y),
- showContent: ({id, elementRect, writingMode, type, details}) => this.showContent(id, elementRect, writingMode, type, details),
- setCustomCss: ({id, css}) => this.setCustomCss(id, css),
- clearAutoPlayTimer: ({id}) => this.clearAutoPlayTimer(id)
- });
+ this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, new Map([
+ ['createNestedPopup', ({parentId}) => this._onApiCreateNestedPopup(parentId)],
+ ['setOptions', ({id, options}) => this._onApiSetOptions(id, options)],
+ ['hide', ({id, changeFocus}) => this._onApiHide(id, changeFocus)],
+ ['isVisible', ({id}) => this._onApiIsVisibleAsync(id)],
+ ['setVisibleOverride', ({id, visible}) => this._onApiSetVisibleOverride(id, visible)],
+ ['containsPoint', ({id, x, y}) => this._onApiContainsPoint(id, x, y)],
+ ['showContent', ({id, elementRect, writingMode, type, details}) => this._onApiShowContent(id, elementRect, writingMode, type, details)],
+ ['setCustomCss', ({id, css}) => this._onApiSetCustomCss(id, css)],
+ ['clearAutoPlayTimer', ({id}) => this._onApiClearAutoPlayTimer(id)]
+ ]));
}
createPopup(parentId, depth) {
- const parent = (typeof parentId === 'string' && hasOwn(this.popups, parentId) ? this.popups[parentId] : null);
- const id = `${this.nextId}`;
- if (parent !== null) {
- depth = parent.depth + 1;
- }
- ++this.nextId;
- const popup = new Popup(id, depth, this.frameIdPromise);
- if (parent !== null) {
- popup.parent = parent;
- parent.child = popup;
- }
- this.popups[id] = popup;
- return popup;
- }
-
- async createNestedPopup(parentId) {
- return this.createPopup(parentId, 0).id;
+ return this._createPopupInternal(parentId, depth).popup;
}
- getPopup(id) {
- if (!hasOwn(this.popups, id)) {
- throw new Error('Invalid popup ID');
- }
-
- return this.popups[id];
- }
+ // Message handlers
- jsonRectToDOMRect(popup, jsonRect) {
- let x = jsonRect.x;
- let y = jsonRect.y;
- if (popup.parent !== null) {
- const popupRect = popup.parent.container.getBoundingClientRect();
- x += popupRect.x;
- y += popupRect.y;
- }
- return new DOMRect(x, y, jsonRect.width, jsonRect.height);
+ async _onApiCreateNestedPopup(parentId) {
+ return this._createPopupInternal(parentId, 0).id;
}
- async setOptions(id, options) {
- const popup = this.getPopup(id);
+ async _onApiSetOptions(id, options) {
+ const popup = this._getPopup(id);
return await popup.setOptions(options);
}
- async hide(id, changeFocus) {
- const popup = this.getPopup(id);
+ async _onApiHide(id, changeFocus) {
+ const popup = this._getPopup(id);
return popup.hide(changeFocus);
}
- async isVisibleAsync(id) {
- const popup = this.getPopup(id);
- return await popup.isVisibleAsync();
+ async _onApiIsVisibleAsync(id) {
+ const popup = this._getPopup(id);
+ return await popup.isVisible();
}
- async setVisibleOverride(id, visible) {
- const popup = this.getPopup(id);
+ async _onApiSetVisibleOverride(id, visible) {
+ const popup = this._getPopup(id);
return await popup.setVisibleOverride(visible);
}
- async containsPoint(id, x, y) {
- const popup = this.getPopup(id);
+ async _onApiContainsPoint(id, x, y) {
+ const popup = this._getPopup(id);
return await popup.containsPoint(x, y);
}
- async showContent(id, elementRect, writingMode, type, details) {
- const popup = this.getPopup(id);
- elementRect = this.jsonRectToDOMRect(popup, elementRect);
- if (!PopupProxyHost.popupCanShow(popup)) { return Promise.resolve(false); }
+ async _onApiShowContent(id, elementRect, writingMode, type, details) {
+ const popup = this._getPopup(id);
+ elementRect = PopupProxyHost._convertJsonRectToDOMRect(popup, elementRect);
+ if (!PopupProxyHost._popupCanShow(popup)) { return; }
return await popup.showContent(elementRect, writingMode, type, details);
}
- async setCustomCss(id, css) {
- const popup = this.getPopup(id);
+ async _onApiSetCustomCss(id, css) {
+ const popup = this._getPopup(id);
return popup.setCustomCss(css);
}
- async clearAutoPlayTimer(id) {
- const popup = this.getPopup(id);
+ async _onApiClearAutoPlayTimer(id) {
+ const popup = this._getPopup(id);
return popup.clearAutoPlayTimer();
}
- static popupCanShow(popup) {
- return popup.parent === null || popup.parent.isVisible();
+ // Private functions
+
+ _createPopupInternal(parentId, depth) {
+ const parent = (typeof parentId === 'string' && this._popups.has(parentId) ? this._popups.get(parentId) : null);
+ const id = `${this._nextId}`;
+ if (parent !== null) {
+ depth = parent.depth + 1;
+ }
+ ++this._nextId;
+ const popup = new Popup(id, depth, this._frameIdPromise);
+ if (parent !== null) {
+ popup.setParent(parent);
+ }
+ this._popups.set(id, popup);
+ return {popup, id};
}
-}
-PopupProxyHost.instance = PopupProxyHost.create();
+ _getPopup(id) {
+ const popup = this._popups.get(id);
+ if (typeof popup === 'undefined') {
+ throw new Error('Invalid popup ID');
+ }
+ return popup;
+ }
+
+ static _convertJsonRectToDOMRect(popup, jsonRect) {
+ let x = jsonRect.x;
+ let y = jsonRect.y;
+ if (popup.parent !== null) {
+ const popupRect = popup.parent.getContainerRect();
+ x += popupRect.x;
+ y += popupRect.y;
+ }
+ return new DOMRect(x, y, jsonRect.width, jsonRect.height);
+ }
+
+ static _popupCanShow(popup) {
+ return popup.parent === null || popup.parent.isVisibleSync();
+ }
+}
diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js
index e62a4868..ae0cffad 100644
--- a/ext/fg/js/popup-proxy.js
+++ b/ext/fg/js/popup-proxy.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,94 +13,113 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
class PopupProxy {
constructor(depth, parentId, parentFrameId, url) {
- this.parentId = parentId;
- this.parentFrameId = parentFrameId;
- this.id = null;
- this.idPromise = null;
- this.parent = null;
- this.child = null;
- this.depth = depth;
- this.url = url;
+ this._parentId = parentId;
+ this._parentFrameId = parentFrameId;
+ this._id = null;
+ this._idPromise = null;
+ this._depth = depth;
+ this._url = url;
+ this._apiSender = new FrontendApiSender();
+ }
- this.container = null;
+ // Public properties
- this.apiSender = new FrontendApiSender();
+ get parent() {
+ return null;
}
- getPopupId() {
- if (this.idPromise === null) {
- this.idPromise = this.getPopupIdAsync();
- }
- return this.idPromise;
+ get depth() {
+ return this._depth;
}
- async getPopupIdAsync() {
- const id = await this.invokeHostApi('createNestedPopup', {parentId: this.parentId});
- this.id = id;
- return id;
+ get url() {
+ return this._url;
+ }
+
+ // Public functions
+
+ isProxy() {
+ return true;
}
async setOptions(options) {
- const id = await this.getPopupId();
- return await this.invokeHostApi('setOptions', {id, options});
+ const id = await this._getPopupId();
+ return await this._invokeHostApi('setOptions', {id, options});
}
- async hide(changeFocus) {
- if (this.id === null) {
+ hide(changeFocus) {
+ if (this._id === null) {
return;
}
- return await this.invokeHostApi('hide', {id: this.id, changeFocus});
+ this._invokeHostApi('hide', {id: this._id, changeFocus});
}
- async isVisibleAsync() {
- const id = await this.getPopupId();
- return await this.invokeHostApi('isVisibleAsync', {id});
+ async isVisible() {
+ const id = await this._getPopupId();
+ return await this._invokeHostApi('isVisible', {id});
}
- async setVisibleOverride(visible) {
- const id = await this.getPopupId();
- return await this.invokeHostApi('setVisibleOverride', {id, visible});
+ setVisibleOverride(visible) {
+ if (this._id === null) {
+ return;
+ }
+ this._invokeHostApi('setVisibleOverride', {id, visible});
}
async containsPoint(x, y) {
- if (this.id === null) {
+ if (this._id === null) {
return false;
}
- return await this.invokeHostApi('containsPoint', {id: this.id, x, y});
+ return await this._invokeHostApi('containsPoint', {id: this._id, x, y});
}
async showContent(elementRect, writingMode, type=null, details=null) {
- const id = await this.getPopupId();
- elementRect = PopupProxy.DOMRectToJson(elementRect);
- return await this.invokeHostApi('showContent', {id, elementRect, writingMode, type, details});
+ const id = await this._getPopupId();
+ elementRect = PopupProxy._convertDOMRectToJson(elementRect);
+ return await this._invokeHostApi('showContent', {id, elementRect, writingMode, type, details});
}
async setCustomCss(css) {
- const id = await this.getPopupId();
- return await this.invokeHostApi('setCustomCss', {id, css});
+ const id = await this._getPopupId();
+ return await this._invokeHostApi('setCustomCss', {id, css});
}
- async clearAutoPlayTimer() {
- if (this.id === null) {
+ clearAutoPlayTimer() {
+ if (this._id === null) {
return;
}
- return await this.invokeHostApi('clearAutoPlayTimer', {id: this.id});
+ this._invokeHostApi('clearAutoPlayTimer', {id: this._id});
+ }
+
+ // Private
+
+ _getPopupId() {
+ if (this._idPromise === null) {
+ this._idPromise = this._getPopupIdAsync();
+ }
+ return this._idPromise;
+ }
+
+ async _getPopupIdAsync() {
+ const id = await this._invokeHostApi('createNestedPopup', {parentId: this._parentId});
+ this._id = id;
+ return id;
}
- invokeHostApi(action, params={}) {
- if (typeof this.parentFrameId !== 'number') {
+ _invokeHostApi(action, params={}) {
+ if (typeof this._parentFrameId !== 'number') {
return Promise.reject(new Error('Invalid frame'));
}
- return this.apiSender.invoke(action, params, `popup-proxy-host#${this.parentFrameId}`);
+ return this._apiSender.invoke(action, params, `popup-proxy-host#${this._parentFrameId}`);
}
- static DOMRectToJson(domRect) {
+ static _convertDOMRectToJson(domRect) {
return {
x: domRect.x,
y: domRect.y,
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
index 42475d96..7a0c6133 100644
--- a/ext/fg/js/popup.js
+++ b/ext/fg/js/popup.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,100 +13,241 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
class Popup {
constructor(id, depth, frameIdPromise) {
- this.id = id;
- this.depth = depth;
- this.frameIdPromise = frameIdPromise;
- this.frameId = null;
- this.parent = null;
- this.child = null;
- this.childrenSupported = true;
- this.container = document.createElement('iframe');
- this.container.className = 'yomichan-float';
- this.container.addEventListener('mousedown', (e) => e.stopPropagation());
- this.container.addEventListener('scroll', (e) => e.stopPropagation());
- this.container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));
- this.container.style.width = '0px';
- this.container.style.height = '0px';
- this.injectPromise = null;
- this.isInjected = false;
- this.visible = false;
- this.visibleOverride = null;
- this.options = null;
- this.stylesheetInjectedViaApi = false;
- this.updateVisibility();
- }
-
- inject() {
- if (this.injectPromise === null) {
- this.injectPromise = this.createInjectPromise();
+ this._id = id;
+ this._depth = depth;
+ this._frameIdPromise = frameIdPromise;
+ this._frameId = null;
+ this._parent = null;
+ this._child = null;
+ this._childrenSupported = true;
+ this._injectPromise = null;
+ this._isInjected = false;
+ this._visible = false;
+ this._visibleOverride = null;
+ this._options = null;
+ this._stylesheetInjectedViaApi = false;
+
+ this._container = document.createElement('iframe');
+ this._container.className = 'yomichan-float';
+ this._container.addEventListener('mousedown', (e) => e.stopPropagation());
+ this._container.addEventListener('scroll', (e) => e.stopPropagation());
+ this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));
+ this._container.style.width = '0px';
+ this._container.style.height = '0px';
+
+ this._updateVisibility();
+ }
+
+ // Public properties
+
+ get parent() {
+ return this._parent;
+ }
+
+ get depth() {
+ return this._depth;
+ }
+
+ get url() {
+ return window.location.href;
+ }
+
+ // Public functions
+
+ isProxy() {
+ return false;
+ }
+
+ async setOptions(options) {
+ this._options = options;
+ this.updateTheme();
+ }
+
+ hide(changeFocus) {
+ if (!this.isVisibleSync()) {
+ return;
+ }
+
+ this._setVisible(false);
+ if (this._child !== null) {
+ this._child.hide(false);
+ }
+ if (changeFocus) {
+ this._focusParent();
}
- return this.injectPromise;
}
- async createInjectPromise() {
+ async isVisible() {
+ return this.isVisibleSync();
+ }
+
+ setVisibleOverride(visible) {
+ this._visibleOverride = visible;
+ this._updateVisibility();
+ }
+
+ async containsPoint(x, y) {
+ for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup._child) {
+ const rect = popup._container.getBoundingClientRect();
+ if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ async showContent(elementRect, writingMode, type=null, details=null) {
+ if (!this._isInitialized()) { return; }
+ await this._show(elementRect, writingMode);
+ if (type === null) { return; }
+ this._invokeApi('setContent', {type, details});
+ }
+
+ async setCustomCss(css) {
+ this._invokeApi('setCustomCss', {css});
+ }
+
+ clearAutoPlayTimer() {
+ if (this._isInjected) {
+ this._invokeApi('clearAutoPlayTimer');
+ }
+ }
+
+ // Popup-only public functions
+
+ setParent(parent) {
+ if (parent === null) {
+ throw new Error('Cannot set popup parent to null');
+ }
+ if (this._parent !== null) {
+ throw new Error('Popup already has a parent');
+ }
+ if (parent._child !== null) {
+ throw new Error('Cannot parent popup to another popup which already has a child');
+ }
+ this._parent = parent;
+ parent._child = this;
+ }
+
+ isVisibleSync() {
+ return this._isInjected && (this._visibleOverride !== null ? this._visibleOverride : this._visible);
+ }
+
+ updateTheme() {
+ this._container.dataset.yomichanTheme = this._options.general.popupOuterTheme;
+ this._container.dataset.yomichanSiteColor = this._getSiteColor();
+ }
+
+ async setCustomOuterCss(css, injectDirectly) {
+ // Cannot repeatedly inject stylesheets using web extension APIs since there is no way to remove them.
+ if (this._stylesheetInjectedViaApi) { return; }
+
+ if (injectDirectly || Popup._isOnExtensionPage()) {
+ Popup.injectOuterStylesheet(css);
+ } else {
+ if (!css) { return; }
+ try {
+ await apiInjectStylesheet(css);
+ this._stylesheetInjectedViaApi = true;
+ } catch (e) {
+ // NOP
+ }
+ }
+ }
+
+ setChildrenSupported(value) {
+ this._childrenSupported = value;
+ }
+
+ getContainer() {
+ return this._container;
+ }
+
+ getContainerRect() {
+ return this._container.getBoundingClientRect();
+ }
+
+ static injectOuterStylesheet(css) {
+ if (Popup.outerStylesheet === null) {
+ if (!css) { return; }
+ Popup.outerStylesheet = document.createElement('style');
+ Popup.outerStylesheet.id = 'yomichan-popup-outer-stylesheet';
+ }
+
+ const outerStylesheet = Popup.outerStylesheet;
+ if (css) {
+ outerStylesheet.textContent = css;
+
+ const par = document.head;
+ if (par && outerStylesheet.parentNode !== par) {
+ par.appendChild(outerStylesheet);
+ }
+ } else {
+ outerStylesheet.textContent = '';
+ }
+ }
+
+ // Private functions
+
+ _inject() {
+ if (this._injectPromise === null) {
+ this._injectPromise = this._createInjectPromise();
+ }
+ return this._injectPromise;
+ }
+
+ async _createInjectPromise() {
try {
- const {frameId} = await this.frameIdPromise;
+ const {frameId} = await this._frameIdPromise;
if (typeof frameId === 'number') {
- this.frameId = frameId;
+ this._frameId = frameId;
}
} catch (e) {
// NOP
}
return new Promise((resolve) => {
- const parentFrameId = (typeof this.frameId === 'number' ? this.frameId : null);
- this.container.addEventListener('load', () => {
- this.invokeApi('initialize', {
- options: this.options,
+ const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null);
+ this._container.addEventListener('load', () => {
+ this._invokeApi('initialize', {
+ options: this._options,
popupInfo: {
- id: this.id,
- depth: this.depth,
+ id: this._id,
+ depth: this._depth,
parentFrameId
},
url: this.url,
- childrenSupported: this.childrenSupported
+ childrenSupported: this._childrenSupported
});
resolve();
});
- this.observeFullscreen();
- this.onFullscreenChanged();
- this.setCustomOuterCss(this.options.general.customPopupOuterCss, false);
- this.isInjected = true;
+ this._observeFullscreen();
+ this._onFullscreenChanged();
+ this.setCustomOuterCss(this._options.general.customPopupOuterCss, false);
+ this._isInjected = true;
});
}
- isInitialized() {
- return this.options !== null;
+ _isInitialized() {
+ return this._options !== null;
}
- async setOptions(options) {
- this.options = options;
- this.updateTheme();
- }
+ async _show(elementRect, writingMode) {
+ await this._inject();
- async showContent(elementRect, writingMode, type=null, details=null) {
- if (!this.isInitialized()) { return; }
- await this.show(elementRect, writingMode);
- if (type === null) { return; }
- this.invokeApi('setContent', {type, details});
- }
-
- async show(elementRect, writingMode) {
- await this.inject();
-
- const optionsGeneral = this.options.general;
- const container = this.container;
+ const optionsGeneral = this._options.general;
+ const container = this._container;
const containerRect = container.getBoundingClientRect();
const getPosition = (
writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ?
- Popup.getPositionForHorizontalText :
- Popup.getPositionForVerticalText
+ Popup._getPositionForHorizontalText :
+ Popup._getPositionForVerticalText
);
const [x, y, width, height, below] = getPosition(
@@ -126,13 +267,78 @@ class Popup {
container.style.width = `${width}px`;
container.style.height = `${height}px`;
- this.setVisible(true);
- if (this.child !== null) {
- this.child.hide(true);
+ this._setVisible(true);
+ if (this._child !== null) {
+ this._child.hide(true);
+ }
+ }
+
+ _setVisible(visible) {
+ this._visible = visible;
+ this._updateVisibility();
+ }
+
+ _updateVisibility() {
+ this._container.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important');
+ }
+
+ _focusParent() {
+ if (this._parent !== null) {
+ // Chrome doesn't like focusing iframe without contentWindow.
+ const contentWindow = this._parent._container.contentWindow;
+ if (contentWindow !== null) {
+ contentWindow.focus();
+ }
+ } else {
+ // Firefox doesn't like focusing window without first blurring the iframe.
+ // this.container.contentWindow.blur() doesn't work on Firefox for some reason.
+ this._container.blur();
+ // This is needed for Chrome.
+ window.focus();
+ }
+ }
+
+ _getSiteColor() {
+ const color = [255, 255, 255];
+ Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.documentElement).backgroundColor));
+ Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.body).backgroundColor));
+ const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
+ return dark ? 'dark' : 'light';
+ }
+
+ _invokeApi(action, params={}) {
+ this._container.contentWindow.postMessage({action, params}, '*');
+ }
+
+ _observeFullscreen() {
+ const fullscreenEvents = [
+ 'fullscreenchange',
+ 'MSFullscreenChange',
+ 'mozfullscreenchange',
+ 'webkitfullscreenchange'
+ ];
+ for (const eventName of fullscreenEvents) {
+ document.addEventListener(eventName, () => this._onFullscreenChanged(), false);
}
}
- static getPositionForHorizontalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral) {
+ _getFullscreenElement() {
+ return (
+ document.fullscreenElement ||
+ document.msFullscreenElement ||
+ document.mozFullScreenElement ||
+ document.webkitFullscreenElement
+ );
+ }
+
+ _onFullscreenChanged() {
+ const parent = (this._getFullscreenElement() || document.body || null);
+ if (parent !== null && this._container.parentNode !== parent) {
+ parent.appendChild(this._container);
+ }
+ }
+
+ static _getPositionForHorizontalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral) {
let x = elementRect.left + optionsGeneral.popupHorizontalOffset;
const overflowX = Math.max(x + width - maxWidth, 0);
if (overflowX > 0) {
@@ -147,7 +353,7 @@ class Popup {
const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below');
const verticalOffset = optionsGeneral.popupVerticalOffset;
- const [y, h, below] = Popup.limitGeometry(
+ const [y, h, below] = Popup._limitGeometry(
elementRect.top - verticalOffset,
elementRect.bottom + verticalOffset,
height,
@@ -158,19 +364,19 @@ class Popup {
return [x, y, width, h, below];
}
- static getPositionForVerticalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral, writingMode) {
- const preferRight = Popup.isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode);
+ static _getPositionForVerticalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral, writingMode) {
+ const preferRight = Popup._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode);
const horizontalOffset = optionsGeneral.popupHorizontalOffset2;
const verticalOffset = optionsGeneral.popupVerticalOffset2;
- const [x, w] = Popup.limitGeometry(
+ const [x, w] = Popup._limitGeometry(
elementRect.left - horizontalOffset,
elementRect.right + horizontalOffset,
width,
maxWidth,
preferRight
);
- const [y, h, below] = Popup.limitGeometry(
+ const [y, h, below] = Popup._limitGeometry(
elementRect.bottom - verticalOffset,
elementRect.top + verticalOffset,
height,
@@ -180,12 +386,12 @@ class Popup {
return [x, y, w, h, below];
}
- static isVerticalTextPopupOnRight(positionPreference, writingMode) {
+ static _isVerticalTextPopupOnRight(positionPreference, writingMode) {
switch (positionPreference) {
case 'before':
- return !Popup.isWritingModeLeftToRight(writingMode);
+ return !Popup._isWritingModeLeftToRight(writingMode);
case 'after':
- return Popup.isWritingModeLeftToRight(writingMode);
+ return Popup._isWritingModeLeftToRight(writingMode);
case 'left':
return false;
case 'right':
@@ -193,7 +399,7 @@ class Popup {
}
}
- static isWritingModeLeftToRight(writingMode) {
+ static _isWritingModeLeftToRight(writingMode) {
switch (writingMode) {
case 'vertical-lr':
case 'sideways-lr':
@@ -203,7 +409,7 @@ class Popup {
}
}
- static limitGeometry(positionBefore, positionAfter, size, limit, preferAfter) {
+ static _limitGeometry(positionBefore, positionAfter, size, limit, preferAfter) {
let after = preferAfter;
let position = 0;
const overflowBefore = Math.max(0, size - positionBefore);
@@ -225,72 +431,7 @@ class Popup {
return [position, size, after];
}
- hide(changeFocus) {
- if (!this.isVisible()) {
- return;
- }
-
- this.setVisible(false);
- if (this.child !== null) {
- this.child.hide(false);
- }
- if (changeFocus) {
- this.focusParent();
- }
- }
-
- async isVisibleAsync() {
- return this.isVisible();
- }
-
- isVisible() {
- return this.isInjected && (this.visibleOverride !== null ? this.visibleOverride : this.visible);
- }
-
- setVisible(visible) {
- this.visible = visible;
- this.updateVisibility();
- }
-
- setVisibleOverride(visible) {
- this.visibleOverride = visible;
- this.updateVisibility();
- }
-
- updateVisibility() {
- this.container.style.setProperty('visibility', this.isVisible() ? 'visible' : 'hidden', 'important');
- }
-
- focusParent() {
- if (this.parent !== null) {
- // Chrome doesn't like focusing iframe without contentWindow.
- const contentWindow = this.parent.container.contentWindow;
- if (contentWindow !== null) {
- contentWindow.focus();
- }
- } else {
- // Firefox doesn't like focusing window without first blurring the iframe.
- // this.container.contentWindow.blur() doesn't work on Firefox for some reason.
- this.container.blur();
- // This is needed for Chrome.
- window.focus();
- }
- }
-
- updateTheme() {
- this.container.dataset.yomichanTheme = this.options.general.popupOuterTheme;
- this.container.dataset.yomichanSiteColor = this.getSiteColor();
- }
-
- getSiteColor() {
- const color = [255, 255, 255];
- Popup.addColor(color, Popup.getColorInfo(window.getComputedStyle(document.documentElement).backgroundColor));
- Popup.addColor(color, Popup.getColorInfo(window.getComputedStyle(document.body).backgroundColor));
- const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
- return dark ? 'dark' : 'light';
- }
-
- static addColor(target, color) {
+ static _addColor(target, color) {
if (color === null) { return; }
const a = color[3];
@@ -302,7 +443,7 @@ class Popup {
}
}
- static getColorInfo(cssColor) {
+ static _getColorInfo(cssColor) {
const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor);
if (m === null) { return null; }
@@ -315,80 +456,7 @@ class Popup {
];
}
- async containsPoint(x, y) {
- for (let popup = this; popup !== null && popup.isVisible(); popup = popup.child) {
- const rect = popup.container.getBoundingClientRect();
- if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
- return true;
- }
- }
- return false;
- }
-
- async setCustomCss(css) {
- this.invokeApi('setCustomCss', {css});
- }
-
- async setCustomOuterCss(css, injectDirectly) {
- // Cannot repeatedly inject stylesheets using web extension APIs since there is no way to remove them.
- if (this.stylesheetInjectedViaApi) { return; }
-
- if (injectDirectly || Popup.isOnExtensionPage()) {
- Popup.injectOuterStylesheet(css);
- } else {
- if (!css) { return; }
- try {
- await apiInjectStylesheet(css);
- this.stylesheetInjectedViaApi = true;
- } catch (e) {
- // NOP
- }
- }
- }
-
- clearAutoPlayTimer() {
- if (this.isInjected) {
- this.invokeApi('clearAutoPlayTimer');
- }
- }
-
- invokeApi(action, params={}) {
- this.container.contentWindow.postMessage({action, params}, '*');
- }
-
- observeFullscreen() {
- const fullscreenEvents = [
- 'fullscreenchange',
- 'MSFullscreenChange',
- 'mozfullscreenchange',
- 'webkitfullscreenchange'
- ];
- for (const eventName of fullscreenEvents) {
- document.addEventListener(eventName, () => this.onFullscreenChanged(), false);
- }
- }
-
- getFullscreenElement() {
- return (
- document.fullscreenElement ||
- document.msFullscreenElement ||
- document.mozFullScreenElement ||
- document.webkitFullscreenElement
- );
- }
-
- onFullscreenChanged() {
- const parent = (this.getFullscreenElement() || document.body || null);
- if (parent !== null && this.container.parentNode !== parent) {
- parent.appendChild(this.container);
- }
- }
-
- get url() {
- return window.location.href;
- }
-
- static isOnExtensionPage() {
+ static _isOnExtensionPage() {
try {
const url = chrome.runtime.getURL('/');
return window.location.href.substring(0, url.length) === url;
@@ -396,26 +464,6 @@ class Popup {
// NOP
}
}
-
- static injectOuterStylesheet(css) {
- if (Popup.outerStylesheet === null) {
- if (!css) { return; }
- Popup.outerStylesheet = document.createElement('style');
- Popup.outerStylesheet.id = 'yomichan-popup-outer-stylesheet';
- }
-
- const outerStylesheet = Popup.outerStylesheet;
- if (css) {
- outerStylesheet.textContent = css;
-
- const par = document.head;
- if (par && outerStylesheet.parentNode !== par) {
- par.appendChild(outerStylesheet);
- }
- } else {
- outerStylesheet.textContent = '';
- }
- }
}
Popup.outerStylesheet = null;
diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js
index a84feed4..5cdf47b5 100644
--- a/ext/fg/js/source.js
+++ b/ext/fg/js/source.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * 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
diff --git a/ext/manifest.json b/ext/manifest.json
index 8e6a063a..5a76157b 100644
--- a/ext/manifest.json
+++ b/ext/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Yomichan (testing)",
- "version": "2.0.1",
+ "version": "20.01.04.00",
"description": "Japanese dictionary with Anki integration (testing)",
"icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"},
@@ -20,7 +20,8 @@
"js": [
"mixed/js/core.js",
"mixed/js/dom.js",
- "fg/js/api.js",
+ "mixed/js/api.js",
+ "mixed/js/text-scanner.js",
"fg/js/document.js",
"fg/js/frontend-api-receiver.js",
"fg/js/popup.js",
diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css
index 681d248c..e26c72aa 100644
--- a/ext/mixed/css/display-dark.css
+++ b/ext/mixed/css/display-dark.css
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css
index add0a9c8..ac237e79 100644
--- a/ext/mixed/css/display-default.css
+++ b/ext/mixed/css/display-default.css
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css
index 9152216f..7a00bccb 100644
--- a/ext/mixed/css/display.css
+++ b/ext/mixed/css/display.css
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -79,10 +79,7 @@ ol, ul {
.term-navigation {
position: fixed;
top: 0px;
-}
-
-.term-button-fade {
- opacity: 0.4;
+ right: 0px;
}
diff --git a/ext/fg/js/api.js b/ext/mixed/js/api.js
index 0e100b59..8ed1d996 100644
--- a/ext/fg/js/api.js
+++ b/ext/mixed/js/api.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,18 +13,30 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+function apiOptionsSchemaGet() {
+ return _apiInvoke('optionsSchemaGet');
+}
+
function apiOptionsGet(optionsContext) {
return _apiInvoke('optionsGet', {optionsContext});
}
+function apiOptionsGetFull() {
+ return _apiInvoke('optionsGetFull');
+}
+
function apiOptionsSet(changedOptions, optionsContext, source) {
return _apiInvoke('optionsSet', {changedOptions, optionsContext, source});
}
+function apiOptionsSave(source) {
+ return _apiInvoke('optionsSave', {source});
+}
+
function apiTermsFind(text, details, optionsContext) {
return _apiInvoke('termsFind', {text, details, optionsContext});
}
@@ -107,8 +119,8 @@ function _apiInvoke(action, params={}) {
}
});
} catch (e) {
- window.yomichan_orphaned = true;
reject(e);
+ yomichan.triggerOrphaned(e);
}
});
}
diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js
index 35f283a4..b0c5fa82 100644
--- a/ext/mixed/js/audio.js
+++ b/ext/mixed/js/audio.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js
index b5911535..54e8a9d2 100644
--- a/ext/mixed/js/core.js
+++ b/ext/mixed/js/core.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -118,6 +118,10 @@ function toIterable(value) {
throw new Error('Could not convert to iterable');
}
+function stringReverse(string) {
+ return string.split('').reverse().join('').replace(/([\uDC00-\uDFFF])([\uD800-\uDBFF])/g, '$2$1');
+}
+
/*
* Async utilities
@@ -175,3 +179,96 @@ function stringReplaceAsync(str, regex, replacer) {
parts.push(str.substring(index));
return Promise.all(parts).then((v) => v.join(''));
}
+
+
+/*
+ * Common events
+ */
+
+class EventDispatcher {
+ constructor() {
+ this._eventMap = new Map();
+ }
+
+ trigger(eventName, details) {
+ const callbacks = this._eventMap.get(eventName);
+ if (typeof callbacks === 'undefined') { return false; }
+
+ for (const callback of callbacks) {
+ callback(details);
+ }
+ }
+
+ on(eventName, callback) {
+ let callbacks = this._eventMap.get(eventName);
+ if (typeof callbacks === 'undefined') {
+ callbacks = [];
+ this._eventMap.set(eventName, callbacks);
+ }
+ callbacks.push(callback);
+ }
+
+ off(eventName, callback) {
+ const callbacks = this._eventMap.get(eventName);
+ if (typeof callbacks === 'undefined') { return true; }
+
+ const ii = callbacks.length;
+ for (let i = 0; i < ii; ++i) {
+ if (callbacks[i] === callback) {
+ callbacks.splice(i, 1);
+ if (callbacks.length === 0) {
+ this._eventMap.delete(eventName);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+
+/*
+ * Default message handlers
+ */
+
+const yomichan = (() => {
+ class Yomichan extends EventDispatcher {
+ constructor() {
+ super();
+
+ this._messageHandlers = new Map([
+ ['getUrl', this._onMessageGetUrl.bind(this)],
+ ['optionsUpdate', this._onMessageOptionsUpdate.bind(this)]
+ ]);
+
+ chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
+ }
+
+ // Public
+
+ triggerOrphaned(error) {
+ this.trigger('orphaned', {error});
+ }
+
+ // Private
+
+ _onMessage({action, params}, sender, callback) {
+ const handler = this._messageHandlers.get(action);
+ if (typeof handler !== 'function') { return false; }
+
+ const result = handler(params, sender);
+ callback(result);
+ return false;
+ }
+
+ _onMessageGetUrl() {
+ return {url: window.location.href};
+ }
+
+ _onMessageOptionsUpdate({source}) {
+ this.trigger('optionsUpdate', {source});
+ }
+ }
+
+ return new Yomichan();
+})();
diff --git a/ext/mixed/js/display-context.js b/ext/mixed/js/display-context.js
index 4b399881..c11c2342 100644
--- a/ext/mixed/js/display-context.js
+++ b/ext/mixed/js/display-context.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index 7d5e4e7d..e756f948 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2017-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -144,7 +144,7 @@ class Display {
try {
e.preventDefault();
- const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options);
+ const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options.scanning.deepDomScan);
if (textSource === null) {
return false;
}
@@ -193,9 +193,8 @@ class Display {
onKeyDown(e) {
const key = Display.getKeyFromEvent(e);
- const handlers = Display.onKeyDownHandlers;
- if (hasOwn(handlers, key)) {
- const handler = handlers[key];
+ const handler = Display._onKeyDownHandlers.get(key);
+ if (typeof handler === 'function') {
if (handler(this, e)) {
e.preventDefault();
return true;
@@ -211,23 +210,18 @@ class Display {
e.preventDefault();
}
} else if (e.shiftKey) {
- const delta = -e.deltaX || e.deltaY;
- if (delta > 0) {
- this.sourceTermView();
- e.preventDefault();
- } else if (delta < 0) {
- this.nextTermView();
- e.preventDefault();
- }
+ this.onHistoryWheel(e);
}
}
- onRuntimeMessage({action, params}, sender, callback) {
- const handlers = Display.runtimeMessageHandlers;
- if (hasOwn(handlers, action)) {
- const handler = handlers[action];
- const result = handler(this, params);
- callback(result);
+ onHistoryWheel(e) {
+ const delta = -e.deltaX || e.deltaY;
+ if (delta > 0) {
+ this.sourceTermView();
+ e.preventDefault();
+ } else if (delta < 0) {
+ this.nextTermView();
+ e.preventDefault();
}
}
@@ -241,7 +235,7 @@ class Display {
async initialize(options=null) {
await this.updateOptions(options);
- chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
+ yomichan.on('optionsUpdate', () => this.updateOptions(null));
}
async updateOptions(options) {
@@ -301,6 +295,7 @@ class Display {
this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this));
this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this));
this.addEventListeners('.next-term', 'click', this.onNextTermView.bind(this));
+ this.addEventListeners('.term-navigation', 'wheel', this.onHistoryWheel.bind(this), {passive: false});
if (this.options.scanning.enablePopupSearch) {
this.addEventListeners('.glossary-item', 'mouseup', this.onGlossaryMouseUp.bind(this));
this.addEventListeners('.glossary-item', 'mousedown', this.onGlossaryMouseDown.bind(this));
@@ -761,101 +756,101 @@ class Display {
}
}
-Display.onKeyDownHandlers = {
- 'Escape': (self) => {
+Display._onKeyDownHandlers = new Map([
+ ['Escape', (self) => {
self.onSearchClear();
return true;
- },
+ }],
- 'PageUp': (self, e) => {
+ ['PageUp', (self, e) => {
if (e.altKey) {
self.entryScrollIntoView(self.index - 3, null, true);
return true;
}
return false;
- },
+ }],
- 'PageDown': (self, e) => {
+ ['PageDown', (self, e) => {
if (e.altKey) {
self.entryScrollIntoView(self.index + 3, null, true);
return true;
}
return false;
- },
+ }],
- 'End': (self, e) => {
+ ['End', (self, e) => {
if (e.altKey) {
self.entryScrollIntoView(self.definitions.length - 1, null, true);
return true;
}
return false;
- },
+ }],
- 'Home': (self, e) => {
+ ['Home', (self, e) => {
if (e.altKey) {
self.entryScrollIntoView(0, null, true);
return true;
}
return false;
- },
+ }],
- 'ArrowUp': (self, e) => {
+ ['ArrowUp', (self, e) => {
if (e.altKey) {
self.entryScrollIntoView(self.index - 1, null, true);
return true;
}
return false;
- },
+ }],
- 'ArrowDown': (self, e) => {
+ ['ArrowDown', (self, e) => {
if (e.altKey) {
self.entryScrollIntoView(self.index + 1, null, true);
return true;
}
return false;
- },
+ }],
- 'B': (self, e) => {
+ ['B', (self, e) => {
if (e.altKey) {
self.sourceTermView();
return true;
}
return false;
- },
+ }],
- 'F': (self, e) => {
+ ['F', (self, e) => {
if (e.altKey) {
self.nextTermView();
return true;
}
return false;
- },
+ }],
- 'E': (self, e) => {
+ ['E', (self, e) => {
if (e.altKey) {
self.noteTryAdd('term-kanji');
return true;
}
return false;
- },
+ }],
- 'K': (self, e) => {
+ ['K', (self, e) => {
if (e.altKey) {
self.noteTryAdd('kanji');
return true;
}
return false;
- },
+ }],
- 'R': (self, e) => {
+ ['R', (self, e) => {
if (e.altKey) {
self.noteTryAdd('term-kana');
return true;
}
return false;
- },
+ }],
- 'P': (self, e) => {
+ ['P', (self, e) => {
if (e.altKey) {
const entry = self.getEntry(self.index);
if (entry !== null && entry.dataset.type === 'term') {
@@ -864,17 +859,13 @@ Display.onKeyDownHandlers = {
return true;
}
return false;
- },
+ }],
- 'V': (self, e) => {
+ ['V', (self, e) => {
if (e.altKey) {
self.noteTryView();
return true;
}
return false;
- }
-};
-
-Display.runtimeMessageHandlers = {
- optionsUpdate: (self) => self.updateOptions(null)
-};
+ }]
+]);
diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js
index 4e4d49e3..807a48e1 100644
--- a/ext/mixed/js/dom.js
+++ b/ext/mixed/js/dom.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js
index 8b841b2e..23b2bd36 100644
--- a/ext/mixed/js/japanese.js
+++ b/ext/mixed/js/japanese.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -160,16 +160,17 @@ function jpDistributeFuriganaInflected(expression, reading, source) {
}
const offset = source.length - stemLength;
- const stemExpression = source.slice(0, source.length - offset);
- const stemReading = reading.slice(
- 0, offset === 0 ? reading.length : reading.length - expression.length + stemLength
+ const stemExpression = source.substring(0, source.length - offset);
+ const stemReading = reading.substring(
+ 0,
+ offset === 0 ? reading.length : reading.length - expression.length + stemLength
);
for (const segment of jpDistributeFurigana(stemExpression, stemReading)) {
output.push(segment);
}
if (stemLength !== source.length) {
- output.push({text: source.slice(stemLength)});
+ output.push({text: source.substring(stemLength)});
}
return output;
diff --git a/ext/mixed/js/scroll.js b/ext/mixed/js/scroll.js
index 824fd92b..5829d294 100644
--- a/ext/mixed/js/scroll.js
+++ b/ext/mixed/js/scroll.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net>
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* This program is free software: you can redistribute it and/or modify
@@ -13,7 +13,7 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js
new file mode 100644
index 00000000..a05dd2ee
--- /dev/null
+++ b/ext/mixed/js/text-scanner.js
@@ -0,0 +1,378 @@
+/*
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+
+class TextScanner {
+ constructor(node, ignoreNodes, ignoreElements, ignorePoints) {
+ this.node = node;
+ this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null);
+ this.ignoreElements = ignoreElements;
+ this.ignorePoints = ignorePoints;
+
+ this.scanTimerPromise = null;
+ this.textSourceCurrent = null;
+ this.pendingLookup = false;
+ this.options = null;
+
+ this.enabled = false;
+ this.eventListeners = [];
+
+ this.primaryTouchIdentifier = null;
+ this.preventNextContextMenu = false;
+ this.preventNextMouseDown = false;
+ this.preventNextClick = false;
+ this.preventScroll = false;
+ }
+
+ onMouseOver(e) {
+ if (this.ignoreElements.includes(e.target)) {
+ this.scanTimerClear();
+ }
+ }
+
+ onMouseMove(e) {
+ this.scanTimerClear();
+
+ if (this.pendingLookup || DOM.isMouseButtonDown(e, 'primary')) {
+ return;
+ }
+
+ const scanningOptions = this.options.scanning;
+ const scanningModifier = scanningOptions.modifier;
+ if (!(
+ TextScanner.isScanningModifierPressed(scanningModifier, e) ||
+ (scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary'))
+ )) {
+ return;
+ }
+
+ const search = async () => {
+ if (scanningModifier === 'none') {
+ if (!await this.scanTimerWait()) {
+ // Aborted
+ return;
+ }
+ }
+
+ await this.searchAt(e.clientX, e.clientY, 'mouse');
+ };
+
+ search();
+ }
+
+ onMouseDown(e) {
+ if (this.preventNextMouseDown) {
+ this.preventNextMouseDown = false;
+ this.preventNextClick = true;
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+
+ if (DOM.isMouseButtonDown(e, 'primary')) {
+ this.scanTimerClear();
+ this.onSearchClear(true);
+ }
+ }
+
+ onMouseOut() {
+ this.scanTimerClear();
+ }
+
+ onClick(e) {
+ if (this.preventNextClick) {
+ this.preventNextClick = false;
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+ }
+
+ onAuxClick() {
+ this.preventNextContextMenu = false;
+ }
+
+ onContextMenu(e) {
+ if (this.preventNextContextMenu) {
+ this.preventNextContextMenu = false;
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+ }
+
+ onTouchStart(e) {
+ if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) {
+ return;
+ }
+
+ this.preventScroll = false;
+ this.preventNextContextMenu = false;
+ this.preventNextMouseDown = false;
+ this.preventNextClick = false;
+
+ const primaryTouch = e.changedTouches[0];
+ if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, this.node.getSelection())) {
+ return;
+ }
+
+ this.primaryTouchIdentifier = primaryTouch.identifier;
+
+ if (this.pendingLookup) {
+ return;
+ }
+
+ const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null;
+
+ this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart')
+ .then(() => {
+ if (
+ this.textSourceCurrent === null ||
+ this.textSourceCurrent.equals(textSourceCurrentPrevious)
+ ) {
+ return;
+ }
+
+ this.preventScroll = true;
+ this.preventNextContextMenu = true;
+ this.preventNextMouseDown = true;
+ });
+ }
+
+ onTouchEnd(e) {
+ if (
+ this.primaryTouchIdentifier === null ||
+ TextScanner.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0
+ ) {
+ return;
+ }
+
+ this.primaryTouchIdentifier = null;
+ this.preventScroll = false;
+ this.preventNextClick = false;
+ // Don't revert context menu and mouse down prevention,
+ // since these events can occur after the touch has ended.
+ // this.preventNextContextMenu = false;
+ // this.preventNextMouseDown = false;
+ }
+
+ onTouchCancel(e) {
+ this.onTouchEnd(e);
+ }
+
+ onTouchMove(e) {
+ if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) {
+ return;
+ }
+
+ const touches = e.changedTouches;
+ const index = TextScanner.getIndexOfTouch(touches, this.primaryTouchIdentifier);
+ if (index < 0) {
+ return;
+ }
+
+ const primaryTouch = touches[index];
+ this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove');
+
+ e.preventDefault(); // Disable scroll
+ }
+
+ async onSearchSource(_textSource, _cause) {
+ throw new Error('Override me');
+ }
+
+ onError(error) {
+ logError(error, false);
+ }
+
+ async scanTimerWait() {
+ const delay = this.options.scanning.delay;
+ const promise = promiseTimeout(delay, true);
+ this.scanTimerPromise = promise;
+ try {
+ return await promise;
+ } finally {
+ if (this.scanTimerPromise === promise) {
+ this.scanTimerPromise = null;
+ }
+ }
+ }
+
+ scanTimerClear() {
+ if (this.scanTimerPromise !== null) {
+ this.scanTimerPromise.resolve(false);
+ this.scanTimerPromise = null;
+ }
+ }
+
+ setEnabled(enabled) {
+ if (enabled) {
+ if (!this.enabled) {
+ this.hookEvents();
+ this.enabled = true;
+ }
+ } else {
+ if (this.enabled) {
+ this.clearEventListeners();
+ this.enabled = false;
+ }
+ this.onSearchClear(false);
+ }
+ }
+
+ hookEvents() {
+ let eventListeners = this.getMouseEventListeners();
+ if (this.options.scanning.touchInputEnabled) {
+ eventListeners = eventListeners.concat(this.getTouchEventListeners());
+ }
+
+ for (const [node, type, listener, options] of eventListeners) {
+ this.addEventListener(node, type, listener, options);
+ }
+ }
+
+ getMouseEventListeners() {
+ return [
+ [this.node, 'mousedown', this.onMouseDown.bind(this)],
+ [this.node, 'mousemove', this.onMouseMove.bind(this)],
+ [this.node, 'mouseover', this.onMouseOver.bind(this)],
+ [this.node, 'mouseout', this.onMouseOut.bind(this)]
+ ];
+ }
+
+ getTouchEventListeners() {
+ return [
+ [this.node, 'click', this.onClick.bind(this)],
+ [this.node, 'auxclick', this.onAuxClick.bind(this)],
+ [this.node, 'touchstart', this.onTouchStart.bind(this)],
+ [this.node, 'touchend', this.onTouchEnd.bind(this)],
+ [this.node, 'touchcancel', this.onTouchCancel.bind(this)],
+ [this.node, 'touchmove', this.onTouchMove.bind(this), {passive: false}],
+ [this.node, 'contextmenu', this.onContextMenu.bind(this)]
+ ];
+ }
+
+ addEventListener(node, type, listener, options) {
+ node.addEventListener(type, listener, options);
+ this.eventListeners.push([node, type, listener, options]);
+ }
+
+ clearEventListeners() {
+ for (const [node, type, listener, options] of this.eventListeners) {
+ node.removeEventListener(type, listener, options);
+ }
+ this.eventListeners = [];
+ }
+
+ setOptions(options) {
+ this.options = options;
+ }
+
+ async searchAt(x, y, cause) {
+ try {
+ this.scanTimerClear();
+
+ if (this.pendingLookup) {
+ return;
+ }
+
+ for (const ignorePointFn of this.ignorePoints) {
+ if (await ignorePointFn(x, y)) {
+ return;
+ }
+ }
+
+ const textSource = docRangeFromPoint(x, y, this.options.scanning.deepDomScan);
+ if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) {
+ return;
+ }
+
+ try {
+ this.pendingLookup = true;
+ const result = await this.onSearchSource(textSource, cause);
+ if (result !== null) {
+ this.textSourceCurrent = textSource;
+ if (this.options.scanning.selectText) {
+ textSource.select();
+ }
+ }
+ this.pendingLookup = false;
+ } finally {
+ if (textSource !== null) {
+ textSource.cleanup();
+ }
+ }
+ } catch (e) {
+ this.onError(e);
+ }
+ }
+
+ setTextSourceScanLength(textSource, length) {
+ textSource.setEndOffset(length);
+ if (this.ignoreNodes === null || !textSource.range) {
+ return;
+ }
+
+ length = textSource.text().length;
+ while (textSource.range && length > 0) {
+ const nodes = TextSourceRange.getNodesInRange(textSource.range);
+ if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) {
+ break;
+ }
+ --length;
+ textSource.setEndOffset(length);
+ }
+ }
+
+ onSearchClear(_) {
+ if (this.textSourceCurrent !== null) {
+ if (this.options.scanning.selectText) {
+ this.textSourceCurrent.deselect();
+ }
+ this.textSourceCurrent = null;
+ }
+ }
+
+ getCurrentTextSource() {
+ return this.textSourceCurrent;
+ }
+
+ setCurrentTextSource(textSource) {
+ return this.textSourceCurrent = textSource;
+ }
+
+ static isScanningModifierPressed(scanningModifier, mouseEvent) {
+ switch (scanningModifier) {
+ case 'alt': return mouseEvent.altKey;
+ case 'ctrl': return mouseEvent.ctrlKey;
+ case 'shift': return mouseEvent.shiftKey;
+ case 'none': return true;
+ default: return false;
+ }
+ }
+
+ static getIndexOfTouch(touchList, identifier) {
+ for (const i in touchList) {
+ const t = touchList[i];
+ if (t.identifier === identifier) {
+ return i;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/ext/mixed/js/timer.js b/ext/mixed/js/timer.js
new file mode 100644
index 00000000..1caf7a05
--- /dev/null
+++ b/ext/mixed/js/timer.js
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+
+class Timer {
+ constructor(name) {
+ this.samples = [];
+ this.parent = null;
+
+ this.sample(name);
+ const current = Timer._current;
+ if (current !== null) {
+ current.samples[current.samples.length - 1].children.push(this);
+ this.parent = current;
+ }
+ Timer._current = this;
+ }
+
+ sample(name) {
+ const time = window.performance.now();
+ this.samples.push({
+ name,
+ time,
+ children: []
+ });
+ }
+
+ complete(skip) {
+ this.sample('complete');
+
+ Timer._current = this.parent;
+ if (this.parent === null) {
+ if (!skip) {
+ console.log(this.toString());
+ }
+ } else {
+ if (skip) {
+ const sample = this.parent.samples[this.parent.samples.length - 1];
+ sample.children.splice(sample.children.length - 1, 1);
+ }
+ }
+ }
+
+ duration(sampleIndex) {
+ const sampleIndexIsValid = (typeof sampleIndex === 'number');
+ const startIndex = (sampleIndexIsValid ? sampleIndex : 0);
+ const endIndex = (sampleIndexIsValid ? sampleIndex + 1 : this.times.length - 1);
+ return (this.times[endIndex].time - this.times[startIndex].time);
+ }
+
+ toString() {
+ const indent = ' ';
+ const name = this.samples[0].name;
+ const duration = this.samples[this.samples.length - 1].time - this.samples[0].time;
+ const extensionName = chrome.runtime.getManifest().name;
+ return `${name} took ${duration.toFixed(8)}ms [${extensionName}]` + Timer._indentString(this.getSampleString(), indent);
+ }
+
+ getSampleString() {
+ const indent = ' ';
+ const duration = this.samples[this.samples.length - 1].time - this.samples[0].time;
+ let message = '';
+
+ for (let i = 0, ii = this.samples.length - 1; i < ii; ++i) {
+ const sample = this.samples[i];
+ const sampleDuration = this.samples[i + 1].time - sample.time;
+ message += `\nSample[${i}] took ${sampleDuration.toFixed(8)}ms (${((sampleDuration / duration) * 100.0).toFixed(1)}%) [${sample.name}]`;
+ for (const child of sample.children) {
+ message += Timer._indentString(child.getSampleString(), indent);
+ }
+ }
+
+ return message;
+ }
+
+ static _indentString(message, indent) {
+ return message.replace(/\n/g, `\n${indent}`);
+ }
+}
+
+Timer._current = null;