summaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
authorAlex Yatskov <alex@foosoft.net>2020-05-22 17:46:16 -0700
committerAlex Yatskov <alex@foosoft.net>2020-05-22 17:46:16 -0700
commit1480288561cb8b9fb87ad711d970c548329fea98 (patch)
tree87c2247f6d144407afcc6de316bbacc264582248 /ext
parentf2186c51e4ef219d158735d30a32bbf3e49c4e1a (diff)
parentd0dcff765f740bf6f0f6523b09cb8b21eb85cd93 (diff)
Merge branch 'master' into testing
Diffstat (limited to 'ext')
-rw-r--r--ext/bg/background.html8
-rw-r--r--ext/bg/context.html3
-rw-r--r--ext/bg/data/default-anki-field-templates.handlebars10
-rw-r--r--ext/bg/data/dictionary-term-bank-v3-schema.json81
-rw-r--r--ext/bg/data/dictionary-term-meta-bank-v3-schema.json26
-rw-r--r--ext/bg/data/options-schema.json8
-rw-r--r--ext/bg/guide.html3
-rw-r--r--ext/bg/js/anki-note-builder.js82
-rw-r--r--ext/bg/js/anki.js148
-rw-r--r--ext/bg/js/audio-uri-builder.js26
-rw-r--r--ext/bg/js/backend-api-forwarder.js6
-rw-r--r--ext/bg/js/backend.js647
-rw-r--r--ext/bg/js/background-main.js25
-rw-r--r--ext/bg/js/conditions.js37
-rw-r--r--ext/bg/js/context-main.js (renamed from ext/bg/js/context.js)13
-rw-r--r--ext/bg/js/database.js47
-rw-r--r--ext/bg/js/dictionary-importer.js92
-rw-r--r--ext/bg/js/handlebars.js21
-rw-r--r--ext/bg/js/japanese.js426
-rw-r--r--ext/bg/js/mecab.js2
-rw-r--r--ext/bg/js/media-utility.js98
-rw-r--r--ext/bg/js/options.js55
-rw-r--r--ext/bg/js/profile-conditions.js200
-rw-r--r--ext/bg/js/search-main.js (renamed from ext/bg/js/search-frontend.js)54
-rw-r--r--ext/bg/js/search-query-parser.js174
-rw-r--r--ext/bg/js/search.js63
-rw-r--r--ext/bg/js/settings/anki.js10
-rw-r--r--ext/bg/js/settings/audio.js9
-rw-r--r--ext/bg/js/settings/backup.js2
-rw-r--r--ext/bg/js/settings/conditions-ui.js163
-rw-r--r--ext/bg/js/settings/dictionaries.js32
-rw-r--r--ext/bg/js/settings/main.js23
-rw-r--r--ext/bg/js/settings/popup-preview-frame-main.js26
-rw-r--r--ext/bg/js/settings/popup-preview-frame.js57
-rw-r--r--ext/bg/js/settings/popup-preview.js23
-rw-r--r--ext/bg/js/settings/profiles.js14
-rw-r--r--ext/bg/js/translator.js24
-rw-r--r--ext/bg/js/util.js69
-rw-r--r--ext/bg/legal.html1
-rw-r--r--ext/bg/search.html9
-rw-r--r--ext/bg/settings-popup-preview.html6
-rw-r--r--ext/bg/settings.html25
-rw-r--r--ext/fg/float.html7
-rw-r--r--ext/fg/js/content-script-main.js (renamed from ext/fg/js/frontend-initialize.js)68
-rw-r--r--ext/fg/js/document.js7
-rw-r--r--ext/fg/js/dom-text-scanner.js551
-rw-r--r--ext/fg/js/float-main.js (renamed from ext/fg/js/popup-nested.js)42
-rw-r--r--ext/fg/js/float.js152
-rw-r--r--ext/fg/js/frame-offset-forwarder.js73
-rw-r--r--ext/fg/js/frontend-api-receiver.js55
-rw-r--r--ext/fg/js/frontend-api-sender.js90
-rw-r--r--ext/fg/js/frontend.js338
-rw-r--r--ext/fg/js/popup-factory.js (renamed from ext/fg/js/popup-proxy-host.js)75
-rw-r--r--ext/fg/js/popup-proxy.js58
-rw-r--r--ext/fg/js/popup.js486
-rw-r--r--ext/fg/js/source.js126
-rw-r--r--ext/manifest.json28
-rw-r--r--ext/mixed/css/display-dark.css96
-rw-r--r--ext/mixed/css/display-default.css96
-rw-r--r--ext/mixed/css/display.css299
-rw-r--r--ext/mixed/display-templates.html1
-rw-r--r--ext/mixed/img/icon32.pngbin0 -> 288 bytes
-rw-r--r--ext/mixed/img/yomichan-icon.svg5
-rw-r--r--ext/mixed/js/api.js196
-rw-r--r--ext/mixed/js/audio-system.js118
-rw-r--r--ext/mixed/js/core.js132
-rw-r--r--ext/mixed/js/display-generator.js85
-rw-r--r--ext/mixed/js/display.js84
-rw-r--r--ext/mixed/js/dom.js24
-rw-r--r--ext/mixed/js/dynamic-loader-sentinel.js18
-rw-r--r--ext/mixed/js/dynamic-loader.js139
-rw-r--r--ext/mixed/js/environment.js114
-rw-r--r--ext/mixed/js/japanese.js505
-rw-r--r--ext/mixed/js/media-loader.js107
-rw-r--r--ext/mixed/js/object-property-accessor.js125
-rw-r--r--ext/mixed/js/text-scanner.js436
76 files changed, 5228 insertions, 2356 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html
index afe9c5d1..ca35a3c6 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -6,6 +6,7 @@
<title>Background</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -20,10 +21,12 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/environment.js"></script>
<script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/anki.js"></script>
<script src="/bg/js/anki-note-builder.js"></script>
+ <script src="/bg/js/backend.js"></script>
<script src="/bg/js/mecab.js"></script>
<script src="/bg/js/audio-uri-builder.js"></script>
<script src="/bg/js/backend-api-forwarder.js"></script>
@@ -34,8 +37,8 @@
<script src="/bg/js/deinflector.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
- <script src="/bg/js/japanese.js"></script>
<script src="/bg/js/json-schema.js"></script>
+ <script src="/bg/js/media-utility.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
<script src="/bg/js/request.js"></script>
@@ -43,7 +46,8 @@
<script src="/bg/js/translator.js"></script>
<script src="/bg/js/util.js"></script>
<script src="/mixed/js/audio-system.js"></script>
+ <script src="/mixed/js/object-property-accessor.js"></script>
- <script src="/bg/js/backend.js"></script>
+ <script src="/bg/js/background-main.js"></script>
</body>
</html>
diff --git a/ext/bg/context.html b/ext/bg/context.html
index 0e50ed7c..93012d70 100644
--- a/ext/bg/context.html
+++ b/ext/bg/context.html
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -185,6 +186,6 @@
<script src="/bg/js/options.js"></script>
<script src="/bg/js/util.js"></script>
- <script src="/bg/js/context.js"></script>
+ <script src="/bg/js/context-main.js"></script>
</body>
</html>
diff --git a/ext/bg/data/default-anki-field-templates.handlebars b/ext/bg/data/default-anki-field-templates.handlebars
index 6061851f..42deae23 100644
--- a/ext/bg/data/default-anki-field-templates.handlebars
+++ b/ext/bg/data/default-anki-field-templates.handlebars
@@ -14,7 +14,11 @@
{{~/if~}}
{{/inline}}
-{{#*inline "audio"}}{{/inline}}
+{{#*inline "audio"}}
+ {{~#if definition.audioFileName~}}
+ [sound:{{definition.audioFileName}}]
+ {{~/if~}}
+{{/inline}}
{{#*inline "character"}}
{{~definition.character~}}
@@ -147,7 +151,7 @@
{{/inline}}
{{#*inline "tags"}}
- {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}
+ {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}
{{/inline}}
{{#*inline "url"}}
@@ -162,4 +166,4 @@
{{~context.document.title~}}
{{/inline}}
-{{~> (lookup . "marker") ~}} \ No newline at end of file
+{{~> (lookup . "marker") ~}}
diff --git a/ext/bg/data/dictionary-term-bank-v3-schema.json b/ext/bg/data/dictionary-term-bank-v3-schema.json
index bb982e36..4790e561 100644
--- a/ext/bg/data/dictionary-term-bank-v3-schema.json
+++ b/ext/bg/data/dictionary-term-bank-v3-schema.json
@@ -31,8 +31,85 @@
"type": "array",
"description": "Array of definitions for the term/expression.",
"items": {
- "type": "string",
- "description": "Single definition for the term/expression."
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Single definition for the term/expression."
+ },
+ {
+ "type": "object",
+ "description": "Single detailed definition for the term/expression.",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of the data for this definition.",
+ "enum": ["text", "image"]
+ }
+ },
+ "oneOf": [
+ {
+ "required": [
+ "type",
+ "text"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["text"]
+ },
+ "text": {
+ "type": "string",
+ "description": "Single definition for the term/expression."
+ }
+ }
+ },
+ {
+ "required": [
+ "type",
+ "path"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["image"]
+ },
+ "path": {
+ "type": "string",
+ "description": "Path to the image file in the archive."
+ },
+ "width": {
+ "type": "integer",
+ "description": "Preferred width of the image.",
+ "minimum": 1
+ },
+ "height": {
+ "type": "integer",
+ "description": "Preferred width of the image.",
+ "minimum": 1
+ },
+ "title": {
+ "type": "string",
+ "description": "Hover text for the image."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description of the image."
+ },
+ "pixelated": {
+ "type": "boolean",
+ "description": "Whether or not the image should appear pixelated at sizes larger than the image's native resolution.",
+ "default": false
+ }
+ }
+ }
+ ]
+ }
+ ]
}
},
{
diff --git a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
index 8475db81..ffffb546 100644
--- a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
+++ b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
@@ -26,8 +26,30 @@
{},
{"enum": ["freq"]},
{
- "type": ["string", "number"],
- "description": "Frequency information for the term or expression."
+ "oneOf": [
+ {
+ "type": ["string", "number"],
+ "description": "Frequency information for the term or expression."
+ },
+ {
+ "type": ["object"],
+ "required": [
+ "reading",
+ "frequency"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "reading": {
+ "type": "string",
+ "description": "Reading for the term or expression."
+ },
+ "frequency": {
+ "type": ["string", "number"],
+ "description": "Frequency information for the term or expression."
+ }
+ }
+ }
+ ]
}
]
},
diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json
index 4f9e694d..656da989 100644
--- a/ext/bg/data/options-schema.json
+++ b/ext/bg/data/options-schema.json
@@ -351,7 +351,7 @@
},
"modifier": {
"type": "string",
- "enum": ["none", "alt", "ctrl", "shift"],
+ "enum": ["none", "alt", "ctrl", "shift", "meta"],
"default": "shift"
},
"deepDomScan": {
@@ -492,6 +492,7 @@
"screenshot",
"terms",
"kanji",
+ "duplicateScope",
"fieldTemplates"
],
"properties": {
@@ -587,6 +588,11 @@
}
}
},
+ "duplicateScope": {
+ "type": "string",
+ "default": "collection",
+ "enum": ["collection", "deck"]
+ },
"fieldTemplates": {
"type": ["string", "null"],
"default": null
diff --git a/ext/bg/guide.html b/ext/bg/guide.html
index ff9c71ee..cde520d1 100644
--- a/ext/bg/guide.html
+++ b/ext/bg/guide.html
@@ -6,6 +6,7 @@
<title>Welcome to Yomichan!</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -25,7 +26,7 @@
</p>
<ol>
- <li>Click on the <img src="/mixed/img/icon16.png" alt> icon in the browser toolbar to open the Yomichan actions dialog.</li>
+ <li>Click on the <img src="/mixed/img/yomichan-icon.svg" alt> icon in the browser toolbar to open the Yomichan actions dialog.</li>
<li>Click on the <em>monkey wrench</em> icon in the middle to open the options page.</li>
<li>Import the dictionaries you wish to use for term and Kanji searches.</li>
<li>Hold down <kbd>Shift</kbd> key or the middle mouse button as you move your mouse over text to display definitions.</li>
diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js
index 8a707006..76199db7 100644
--- a/ext/bg/js/anki-note-builder.js
+++ b/ext/bg/js/anki-note-builder.js
@@ -16,7 +16,8 @@
*/
class AnkiNoteBuilder {
- constructor({audioSystem, renderTemplate}) {
+ constructor({anki, audioSystem, renderTemplate}) {
+ this._anki = anki;
this._audioSystem = audioSystem;
this._renderTemplate = renderTemplate;
}
@@ -31,32 +32,16 @@ class AnkiNoteBuilder {
fields: {},
tags,
deckName: modeOptions.deck,
- modelName: modeOptions.model
+ modelName: modeOptions.model,
+ options: {
+ duplicateScope: options.anki.duplicateScope
+ }
};
for (const [fieldName, fieldValue] of modeOptionsFieldEntries) {
note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null);
}
- if (!isKanji && definition.audio) {
- const audioFields = [];
-
- for (const [fieldName, fieldValue] of modeOptionsFieldEntries) {
- if (fieldValue.includes('{audio}')) {
- audioFields.push(fieldName);
- }
- }
-
- if (audioFields.length > 0) {
- note.audio = {
- url: definition.audio.url,
- filename: definition.audio.filename,
- skipHash: '7e2c2f954ef6051373ba916f000168dc', // hash of audio data that should be skipped
- fields: audioFields
- };
- }
- }
-
return note;
}
@@ -84,48 +69,64 @@ class AnkiNoteBuilder {
});
}
- async injectAudio(definition, fields, sources, optionsContext) {
+ async injectAudio(definition, fields, sources, customSourceUrl) {
if (!this._containsMarker(fields, 'audio')) { return; }
try {
const expressions = definition.expressions;
const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;
- const {uri} = await this._audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext});
- const filename = this._createInjectedAudioFileName(audioSourceDefinition);
- if (filename !== null) {
- definition.audio = {url: uri, filename};
- }
+ let fileName = this._createInjectedAudioFileName(audioSourceDefinition);
+ if (fileName === null) { return; }
+ fileName = AnkiNoteBuilder.replaceInvalidFileNameCharacters(fileName);
+
+ const {audio} = await this._audioSystem.getDefinitionAudio(
+ audioSourceDefinition,
+ sources,
+ {
+ textToSpeechVoice: null,
+ customSourceUrl,
+ binary: true,
+ disableCache: true
+ }
+ );
+
+ const data = AnkiNoteBuilder.arrayBufferToBase64(audio);
+ await this._anki.storeMediaFile(fileName, data);
+
+ definition.audioFileName = fileName;
} catch (e) {
// NOP
}
}
- async injectScreenshot(definition, fields, screenshot, anki) {
+ async injectScreenshot(definition, fields, screenshot) {
if (!this._containsMarker(fields, 'screenshot')) { return; }
const now = new Date(Date.now());
- const filename = `yomichan_browser_screenshot_${definition.reading}_${this._dateToString(now)}.${screenshot.format}`;
+ let fileName = `yomichan_browser_screenshot_${definition.reading}_${this._dateToString(now)}.${screenshot.format}`;
+ fileName = AnkiNoteBuilder.replaceInvalidFileNameCharacters(fileName);
const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, '');
try {
- await anki.storeMediaFile(filename, data);
+ await this._anki.storeMediaFile(fileName, data);
} catch (e) {
return;
}
- definition.screenshotFileName = filename;
+ definition.screenshotFileName = fileName;
}
_createInjectedAudioFileName(definition) {
const {reading, expression} = definition;
if (!reading && !expression) { return null; }
- let filename = 'yomichan';
- if (reading) { filename += `_${reading}`; }
- if (expression) { filename += `_${expression}`; }
- filename += '.mp3';
- return filename;
+ let fileName = 'yomichan';
+ if (reading) { fileName += `_${reading}`; }
+ if (expression) { fileName += `_${expression}`; }
+ fileName += '.mp3';
+ fileName = fileName.replace(/\]/g, '');
+ return fileName;
}
_dateToString(date) {
@@ -148,6 +149,15 @@ class AnkiNoteBuilder {
return false;
}
+ static replaceInvalidFileNameCharacters(fileName) {
+ // eslint-disable-next-line no-control-regex
+ return fileName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-');
+ }
+
+ static arrayBufferToBase64(arrayBuffer) {
+ return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
+ }
+
static stringReplaceAsync(str, regex, replacer) {
let match;
let index = 0;
diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js
index c7f7c0cc..55953007 100644
--- a/ext/bg/js/anki.js
+++ b/ext/bg/js/anki.js
@@ -19,122 +19,118 @@
* requestJson
*/
-/*
- * AnkiConnect
- */
-
class AnkiConnect {
constructor(server) {
- this.server = server;
- this.localVersion = 2;
- this.remoteVersion = 0;
+ this._enabled = false;
+ this._server = server;
+ this._localVersion = 2;
+ this._remoteVersion = 0;
+ }
+
+ setServer(server) {
+ this._server = server;
+ }
+
+ getServer() {
+ return this._server;
+ }
+
+ setEnabled(enabled) {
+ this._enabled = enabled;
+ }
+
+ isEnabled() {
+ return this._enabled;
}
async addNote(note) {
- await this.checkVersion();
- return await this.ankiInvoke('addNote', {note});
+ if (!this._enabled) { return null; }
+ await this._checkVersion();
+ return await this._invoke('addNote', {note});
}
async canAddNotes(notes) {
- await this.checkVersion();
- return await this.ankiInvoke('canAddNotes', {notes});
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('canAddNotes', {notes});
}
async getDeckNames() {
- await this.checkVersion();
- return await this.ankiInvoke('deckNames');
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('deckNames');
}
async getModelNames() {
- await this.checkVersion();
- return await this.ankiInvoke('modelNames');
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('modelNames');
}
async getModelFieldNames(modelName) {
- await this.checkVersion();
- return await this.ankiInvoke('modelFieldNames', {modelName});
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('modelFieldNames', {modelName});
}
async guiBrowse(query) {
- await this.checkVersion();
- return await this.ankiInvoke('guiBrowse', {query});
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('guiBrowse', {query});
}
- async storeMediaFile(filename, dataBase64) {
- await this.checkVersion();
- return await this.ankiInvoke('storeMediaFile', {filename, data: dataBase64});
+ async storeMediaFile(fileName, dataBase64) {
+ if (!this._enabled) {
+ throw new Error('AnkiConnect not enabled');
+ }
+ await this._checkVersion();
+ return await this._invoke('storeMediaFile', {filename: fileName, data: dataBase64});
}
- async checkVersion() {
- if (this.remoteVersion < this.localVersion) {
- this.remoteVersion = await this.ankiInvoke('version');
- if (this.remoteVersion < this.localVersion) {
+ async findNoteIds(notes, duplicateScope) {
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ const actions = notes.map((note) => {
+ let query = (duplicateScope === 'deck' ? `"deck:${this._escapeQuery(note.deckName)}" ` : '');
+ query += this._fieldsToQuery(note.fields);
+ return {action: 'findNotes', params: {query}};
+ });
+ return await this._invoke('multi', {actions});
+ }
+
+ // Private
+
+ async _checkVersion() {
+ if (this._remoteVersion < this._localVersion) {
+ this._remoteVersion = await this._invoke('version');
+ if (this._remoteVersion < this._localVersion) {
throw new Error('Extension and plugin versions incompatible');
}
}
}
- async findNoteIds(notes) {
- await this.checkVersion();
- const actions = notes.map((note) => ({
- action: 'findNotes',
- params: {
- query: `deck:"${AnkiConnect.escapeQuery(note.deckName)}" ${AnkiConnect.fieldsToQuery(note.fields)}`
+ async _invoke(action, params) {
+ const result = await requestJson(this._server, 'POST', {action, params, version: this._localVersion});
+ if (isObject(result)) {
+ const error = result.error;
+ if (typeof error !== 'undefined') {
+ throw new Error(`AnkiConnect error: ${error}`);
}
- }));
- return await this.ankiInvoke('multi', {actions});
- }
-
- ankiInvoke(action, params) {
- return requestJson(this.server, 'POST', {action, params, version: this.localVersion});
+ }
+ return result;
}
- static escapeQuery(text) {
+ _escapeQuery(text) {
return text.replace(/"/g, '');
}
- static fieldsToQuery(fields) {
+ _fieldsToQuery(fields) {
const fieldNames = Object.keys(fields);
if (fieldNames.length === 0) {
return '';
}
const key = fieldNames[0];
- return `${key.toLowerCase()}:"${AnkiConnect.escapeQuery(fields[key])}"`;
- }
-}
-
-
-/*
- * AnkiNull
- */
-
-class AnkiNull {
- async addNote() {
- return null;
- }
-
- async canAddNotes() {
- return [];
- }
-
- async getDeckNames() {
- return [];
- }
-
- async getModelNames() {
- return [];
- }
-
- async getModelFieldNames() {
- return [];
- }
-
- async guiBrowse() {
- return [];
- }
-
- async findNoteIds() {
- return [];
+ return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`;
}
}
diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js
index dfd195d8..27e97680 100644
--- a/ext/bg/js/audio-uri-builder.js
+++ b/ext/bg/js/audio-uri-builder.js
@@ -49,11 +49,11 @@ class AudioUriBuilder {
return url;
}
- async getUri(definition, source, options) {
+ async getUri(definition, source, details) {
const handler = this._getUrlHandlers.get(source);
if (typeof handler === 'function') {
try {
- return await handler(definition, options);
+ return await handler(definition, details);
} catch (e) {
// NOP
}
@@ -132,26 +132,24 @@ class AudioUriBuilder {
throw new Error('Failed to find audio URL');
}
- async _getUriTextToSpeech(definition, options) {
- const voiceURI = options.audio.textToSpeechVoice;
- if (!voiceURI) {
+ async _getUriTextToSpeech(definition, {textToSpeechVoice}) {
+ if (!textToSpeechVoice) {
throw new Error('No voice');
}
-
- return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
+ return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`;
}
- async _getUriTextToSpeechReading(definition, options) {
- const voiceURI = options.audio.textToSpeechVoice;
- if (!voiceURI) {
+ async _getUriTextToSpeechReading(definition, {textToSpeechVoice}) {
+ if (!textToSpeechVoice) {
throw new Error('No voice');
}
-
- return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
+ return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`;
}
- async _getUriCustom(definition, options) {
- const customSourceUrl = options.audio.customSourceUrl;
+ async _getUriCustom(definition, {customSourceUrl}) {
+ if (typeof customSourceUrl !== 'string') {
+ throw new Error('No custom URL defined');
+ }
return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0));
}
}
diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js
index 93db77d7..4ac12730 100644
--- a/ext/bg/js/backend-api-forwarder.js
+++ b/ext/bg/js/backend-api-forwarder.js
@@ -17,11 +17,11 @@
class BackendApiForwarder {
- constructor() {
- chrome.runtime.onConnect.addListener(this.onConnect.bind(this));
+ prepare() {
+ chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
}
- onConnect(port) {
+ _onConnect(port) {
if (port.name !== 'backend-api-forwarder') { return; }
let tabId;
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index 2265c1a9..20d31efc 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -18,24 +18,25 @@
/* global
* AnkiConnect
* AnkiNoteBuilder
- * AnkiNull
* AudioSystem
* AudioUriBuilder
* BackendApiForwarder
* ClipboardMonitor
* Database
* DictionaryImporter
+ * Environment
* JsonSchema
* Mecab
+ * ObjectPropertyAccessor
* Translator
* conditionsTestValue
- * dictConfigured
* dictTermsSort
* handlebarsRenderDynamic
* jp
* optionsLoad
* optionsSave
* profileConditionsDescriptor
+ * profileConditionsDescriptorPromise
* requestJson
* requestText
* utilIsolate
@@ -43,18 +44,23 @@
class Backend {
constructor() {
+ this.environment = new Environment();
this.database = new Database();
this.dictionaryImporter = new DictionaryImporter();
this.translator = new Translator(this.database);
- this.anki = new AnkiNull();
+ this.anki = new AnkiConnect();
this.mecab = new Mecab();
this.clipboardMonitor = new ClipboardMonitor({getClipboard: this._onApiClipboardGet.bind(this)});
this.options = null;
this.optionsSchema = null;
this.defaultAnkiFieldTemplates = null;
- this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)});
this.audioUriBuilder = new AudioUriBuilder();
+ this.audioSystem = new AudioSystem({
+ audioUriBuilder: this.audioUriBuilder,
+ useCache: false
+ });
this.ankiNoteBuilder = new AnkiNoteBuilder({
+ anki: this.anki,
audioSystem: this.audioSystem,
renderTemplate: this._renderTemplate.bind(this)
});
@@ -64,89 +70,128 @@ class Backend {
url: window.location.href
};
- this.isPrepared = false;
-
this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target');
this.popupWindow = null;
- this.apiForwarder = new BackendApiForwarder();
+ const apiForwarder = new BackendApiForwarder();
+ apiForwarder.prepare();
- this.messageToken = yomichan.generateId(16);
+ this._defaultBrowserActionTitle = null;
+ this._isPrepared = false;
+ this._prepareError = false;
+ this._badgePrepareDelayTimer = null;
+ this._logErrorLevel = null;
this._messageHandlers = new Map([
- ['yomichanCoreReady', {handler: this._onApiYomichanCoreReady.bind(this), async: false}],
- ['optionsSchemaGet', {handler: this._onApiOptionsSchemaGet.bind(this), async: false}],
- ['optionsGet', {handler: this._onApiOptionsGet.bind(this), async: false}],
- ['optionsGetFull', {handler: this._onApiOptionsGetFull.bind(this), async: false}],
- ['optionsSet', {handler: this._onApiOptionsSet.bind(this), async: true}],
- ['optionsSave', {handler: this._onApiOptionsSave.bind(this), async: true}],
- ['kanjiFind', {handler: this._onApiKanjiFind.bind(this), async: true}],
- ['termsFind', {handler: this._onApiTermsFind.bind(this), async: true}],
- ['textParse', {handler: this._onApiTextParse.bind(this), async: true}],
- ['definitionAdd', {handler: this._onApiDefinitionAdd.bind(this), async: true}],
- ['definitionsAddable', {handler: this._onApiDefinitionsAddable.bind(this), async: true}],
- ['noteView', {handler: this._onApiNoteView.bind(this), async: true}],
- ['templateRender', {handler: this._onApiTemplateRender.bind(this), async: true}],
- ['commandExec', {handler: this._onApiCommandExec.bind(this), async: false}],
- ['audioGetUri', {handler: this._onApiAudioGetUri.bind(this), async: true}],
- ['screenshotGet', {handler: this._onApiScreenshotGet.bind(this), async: true}],
- ['broadcastTab', {handler: this._onApiBroadcastTab.bind(this), async: false}],
- ['frameInformationGet', {handler: this._onApiFrameInformationGet.bind(this), async: true}],
- ['injectStylesheet', {handler: this._onApiInjectStylesheet.bind(this), async: true}],
- ['getEnvironmentInfo', {handler: this._onApiGetEnvironmentInfo.bind(this), async: true}],
- ['clipboardGet', {handler: this._onApiClipboardGet.bind(this), async: true}],
- ['getDisplayTemplatesHtml', {handler: this._onApiGetDisplayTemplatesHtml.bind(this), async: true}],
- ['getQueryParserTemplatesHtml', {handler: this._onApiGetQueryParserTemplatesHtml.bind(this), async: true}],
- ['getZoom', {handler: this._onApiGetZoom.bind(this), async: true}],
- ['getMessageToken', {handler: this._onApiGetMessageToken.bind(this), async: false}],
- ['getDefaultAnkiFieldTemplates', {handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this), async: false}]
+ ['yomichanCoreReady', {async: false, contentScript: true, handler: this._onApiYomichanCoreReady.bind(this)}],
+ ['optionsSchemaGet', {async: false, contentScript: true, handler: this._onApiOptionsSchemaGet.bind(this)}],
+ ['optionsGet', {async: false, contentScript: true, handler: this._onApiOptionsGet.bind(this)}],
+ ['optionsGetFull', {async: false, contentScript: true, handler: this._onApiOptionsGetFull.bind(this)}],
+ ['optionsSave', {async: true, contentScript: true, handler: this._onApiOptionsSave.bind(this)}],
+ ['kanjiFind', {async: true, contentScript: true, handler: this._onApiKanjiFind.bind(this)}],
+ ['termsFind', {async: true, contentScript: true, handler: this._onApiTermsFind.bind(this)}],
+ ['textParse', {async: true, contentScript: true, handler: this._onApiTextParse.bind(this)}],
+ ['definitionAdd', {async: true, contentScript: true, handler: this._onApiDefinitionAdd.bind(this)}],
+ ['definitionsAddable', {async: true, contentScript: true, handler: this._onApiDefinitionsAddable.bind(this)}],
+ ['noteView', {async: true, contentScript: true, handler: this._onApiNoteView.bind(this)}],
+ ['templateRender', {async: true, contentScript: true, handler: this._onApiTemplateRender.bind(this)}],
+ ['commandExec', {async: false, contentScript: true, handler: this._onApiCommandExec.bind(this)}],
+ ['audioGetUri', {async: true, contentScript: true, handler: this._onApiAudioGetUri.bind(this)}],
+ ['screenshotGet', {async: true, contentScript: true, handler: this._onApiScreenshotGet.bind(this)}],
+ ['sendMessageToFrame', {async: false, contentScript: true, handler: this._onApiSendMessageToFrame.bind(this)}],
+ ['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}],
+ ['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}],
+ ['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.bind(this)}],
+ ['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}],
+ ['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}],
+ ['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.bind(this)}],
+ ['getQueryParserTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetQueryParserTemplatesHtml.bind(this)}],
+ ['getZoom', {async: true, contentScript: true, handler: this._onApiGetZoom.bind(this)}],
+ ['getDefaultAnkiFieldTemplates', {async: false, contentScript: true, handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this)}],
+ ['getAnkiDeckNames', {async: true, contentScript: false, handler: this._onApiGetAnkiDeckNames.bind(this)}],
+ ['getAnkiModelNames', {async: true, contentScript: false, handler: this._onApiGetAnkiModelNames.bind(this)}],
+ ['getAnkiModelFieldNames', {async: true, contentScript: false, handler: this._onApiGetAnkiModelFieldNames.bind(this)}],
+ ['getDictionaryInfo', {async: true, contentScript: false, handler: this._onApiGetDictionaryInfo.bind(this)}],
+ ['getDictionaryCounts', {async: true, contentScript: false, handler: this._onApiGetDictionaryCounts.bind(this)}],
+ ['purgeDatabase', {async: true, contentScript: false, handler: this._onApiPurgeDatabase.bind(this)}],
+ ['getMedia', {async: true, contentScript: true, handler: this._onApiGetMedia.bind(this)}],
+ ['log', {async: false, contentScript: true, handler: this._onApiLog.bind(this)}],
+ ['logIndicatorClear', {async: false, contentScript: true, handler: this._onApiLogIndicatorClear.bind(this)}],
+ ['createActionPort', {async: false, contentScript: true, handler: this._onApiCreateActionPort.bind(this)}],
+ ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}]
+ ]);
+ this._messageHandlersWithProgress = new Map([
+ ['importDictionaryArchive', {async: true, contentScript: false, handler: this._onApiImportDictionaryArchive.bind(this)}],
+ ['deleteDictionary', {async: true, contentScript: false, handler: this._onApiDeleteDictionary.bind(this)}]
]);
this._commandHandlers = new Map([
- ['search', this._onCommandSearch.bind(this)],
- ['help', this._onCommandHelp.bind(this)],
+ ['search', this._onCommandSearch.bind(this)],
+ ['help', this._onCommandHelp.bind(this)],
['options', this._onCommandOptions.bind(this)],
- ['toggle', this._onCommandToggle.bind(this)]
+ ['toggle', this._onCommandToggle.bind(this)]
]);
}
async prepare() {
- await this.database.prepare();
- await this.translator.prepare();
-
- this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET');
- this.defaultAnkiFieldTemplates = await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET');
- this.options = await optionsLoad();
try {
+ this._defaultBrowserActionTitle = await this._getBrowserIconTitle();
+ this._badgePrepareDelayTimer = setTimeout(() => {
+ this._badgePrepareDelayTimer = null;
+ this._updateBadge();
+ }, 1000);
+ this._updateBadge();
+
+ await this.environment.prepare();
+ await this.database.prepare();
+ await this.translator.prepare();
+
+ await profileConditionsDescriptorPromise;
+
+ this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET');
+ this.defaultAnkiFieldTemplates = (await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET')).trim();
+ this.options = await optionsLoad();
this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, this.options);
- } catch (e) {
- // This shouldn't happen, but catch errors just in case of bugs
- logError(e);
- }
- this.onOptionsUpdated('background');
+ this.onOptionsUpdated('background');
- if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) {
- chrome.commands.onCommand.addListener(this._runCommand.bind(this));
- }
- if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) {
- chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this));
- }
- chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
+ if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) {
+ chrome.commands.onCommand.addListener(this._runCommand.bind(this));
+ }
+ if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) {
+ chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this));
+ }
+ chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
+
+ const options = this.getOptions(this.optionsContext);
+ if (options.general.showGuide) {
+ chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')});
+ }
- this.isPrepared = true;
+ this.clipboardMonitor.on('change', this._onClipboardText.bind(this));
- const options = this.getOptions(this.optionsContext);
- if (options.general.showGuide) {
- chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')});
- }
+ this._sendMessageAllTabs('backendPrepared');
+ const callback = () => this.checkLastError(chrome.runtime.lastError);
+ chrome.runtime.sendMessage({action: 'backendPrepared'}, callback);
- this.clipboardMonitor.on('change', this._onClipboardText.bind(this));
+ this._isPrepared = true;
+ } catch (e) {
+ this._prepareError = true;
+ yomichan.logError(e);
+ throw e;
+ } finally {
+ if (this._badgePrepareDelayTimer !== null) {
+ clearTimeout(this._badgePrepareDelayTimer);
+ this._badgePrepareDelayTimer = null;
+ }
- this._sendMessageAllTabs('backendPrepared');
- const callback = () => this.checkLastError(chrome.runtime.lastError);
- chrome.runtime.sendMessage({action: 'backendPrepared'}, callback);
+ this._updateBadge();
+ }
+ }
+
+ isPrepared() {
+ return this._isPrepared;
}
_sendMessageAllTabs(action, params={}) {
@@ -167,9 +212,13 @@ class Backend {
const messageHandler = this._messageHandlers.get(action);
if (typeof messageHandler === 'undefined') { return false; }
- const {handler, async} = messageHandler;
+ const {handler, async, contentScript} = messageHandler;
try {
+ if (!contentScript) {
+ this._validatePrivilegedMessageSender(sender);
+ }
+
const promiseOrResult = handler(params, sender);
if (async) {
promiseOrResult.then(
@@ -198,17 +247,10 @@ class Backend {
applyOptions() {
const options = this.getOptions(this.optionsContext);
- if (!options.general.enable) {
- this.setExtensionBadgeBackgroundColor('#555555');
- this.setExtensionBadgeText('off');
- } else if (!dictConfigured(options)) {
- this.setExtensionBadgeBackgroundColor('#f0ad4e');
- this.setExtensionBadgeText('!');
- } else {
- this.setExtensionBadgeText('');
- }
+ this._updateBadge();
- this.anki = options.anki.enable ? new AnkiConnect(options.anki.server) : new AnkiNull();
+ this.anki.setServer(options.anki.server);
+ this.anki.setEnabled(options.anki.enable);
if (options.parsing.enableMecabParser) {
this.mecab.startListener();
@@ -227,8 +269,9 @@ class Backend {
return this.optionsSchema;
}
- getFullOptions() {
- return this.options;
+ getFullOptions(useSchema=false) {
+ const options = this.options;
+ return useSchema ? JsonSchema.createProxy(options, this.optionsSchema) : options;
}
setFullOptions(options) {
@@ -236,25 +279,26 @@ class Backend {
this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options));
} catch (e) {
// This shouldn't happen, but catch errors just in case of bugs
- logError(e);
+ yomichan.logError(e);
}
}
- getOptions(optionsContext) {
- return this.getProfile(optionsContext).options;
+ getOptions(optionsContext, useSchema=false) {
+ return this.getProfile(optionsContext, useSchema).options;
}
- getProfile(optionsContext) {
- const profiles = this.options.profiles;
+ getProfile(optionsContext, useSchema=false) {
+ const options = this.getFullOptions(useSchema);
+ const profiles = options.profiles;
if (typeof optionsContext.index === 'number') {
return profiles[optionsContext.index];
}
- const profile = this.getProfileFromContext(optionsContext);
- return profile !== null ? profile : this.options.profiles[this.options.profileCurrent];
+ const profile = this.getProfileFromContext(options, optionsContext);
+ return profile !== null ? profile : options.profiles[options.profileCurrent];
}
- getProfileFromContext(optionsContext) {
- for (const profile of this.options.profiles) {
+ getProfileFromContext(options, optionsContext) {
+ for (const profile of options.profiles) {
const conditionGroups = profile.conditionGroups;
if (conditionGroups.length > 0 && Backend.testConditionGroups(conditionGroups, optionsContext)) {
return profile;
@@ -285,18 +329,6 @@ class Backend {
return true;
}
- setExtensionBadgeBackgroundColor(color) {
- if (typeof chrome.browserAction.setBadgeBackgroundColor === 'function') {
- chrome.browserAction.setBadgeBackgroundColor({color});
- }
- }
-
- setExtensionBadgeText(text) {
- if (typeof chrome.browserAction.setBadgeText === 'function') {
- chrome.browserAction.setBadgeText({text});
- }
- }
-
checkLastError() {
// NOP
}
@@ -394,46 +426,6 @@ class Backend {
return this.getFullOptions();
}
- async _onApiOptionsSet({changedOptions, optionsContext, source}) {
- const options = this.getOptions(optionsContext);
-
- function getValuePaths(obj) {
- const valuePaths = [];
- const nodes = [{obj, path: []}];
- while (nodes.length > 0) {
- const node = nodes.pop();
- for (const key of Object.keys(node.obj)) {
- const path = node.path.concat(key);
- const obj2 = node.obj[key];
- if (obj2 !== null && typeof obj2 === 'object') {
- nodes.unshift({obj: obj2, path});
- } else {
- valuePaths.push([obj2, path]);
- }
- }
- }
- return valuePaths;
- }
-
- function modifyOption(path, value) {
- let pivot = options;
- for (const key of path.slice(0, -1)) {
- if (!hasOwn(pivot, key)) {
- return false;
- }
- pivot = pivot[key];
- }
- pivot[path[path.length - 1]] = value;
- return true;
- }
-
- for (const [value, path] of getValuePaths(changedOptions)) {
- modifyOption(path, value);
- }
-
- await this._onApiOptionsSave({source});
- }
-
async _onApiOptionsSave({source}) {
const options = this.getFullOptions();
await optionsSave(options);
@@ -484,14 +476,15 @@ class Backend {
async _onApiDefinitionAdd({definition, mode, context, details, optionsContext}) {
const options = this.getOptions(optionsContext);
- const templates = this.defaultAnkiFieldTemplates;
+ const templates = this._getTemplates(options);
if (mode !== 'kanji') {
+ const {customSourceUrl} = options.audio;
await this.ankiNoteBuilder.injectAudio(
definition,
options.anki.terms.fields,
options.audio.sources,
- optionsContext
+ customSourceUrl
);
}
@@ -499,8 +492,7 @@ class Backend {
await this.ankiNoteBuilder.injectScreenshot(
definition,
options.anki.terms.fields,
- details.screenshot,
- this.anki
+ details.screenshot
);
}
@@ -510,7 +502,7 @@ class Backend {
async _onApiDefinitionsAddable({definitions, modes, context, optionsContext}) {
const options = this.getOptions(optionsContext);
- const templates = this.defaultAnkiFieldTemplates;
+ const templates = this._getTemplates(options);
const states = [];
try {
@@ -540,7 +532,7 @@ class Backend {
}
if (cannotAdd.length > 0) {
- const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0]));
+ const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0]), options.anki.duplicateScope);
for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) {
const noteIds = noteIdsArray[i];
if (noteIds.length > 0) {
@@ -567,9 +559,8 @@ class Backend {
return this._runCommand(command, params);
}
- async _onApiAudioGetUri({definition, source, optionsContext}) {
- const options = this.getOptions(optionsContext);
- return await this.audioUriBuilder.getUri(definition, source, options);
+ async _onApiAudioGetUri({definition, source, details}) {
+ return await this.audioUriBuilder.getUri(definition, source, details);
}
_onApiScreenshotGet({options}, sender) {
@@ -583,6 +574,17 @@ class Backend {
});
}
+ _onApiSendMessageToFrame({frameId, action, params}, sender) {
+ if (!(sender && sender.tab)) {
+ return false;
+ }
+
+ const tabId = sender.tab.id;
+ const callback = () => this.checkLastError(chrome.runtime.lastError);
+ chrome.tabs.sendMessage(tabId, {action, params}, {frameId}, callback);
+ return true;
+ }
+
_onApiBroadcastTab({action, params}, sender) {
if (!(sender && sender.tab)) {
return false;
@@ -639,15 +641,8 @@ class Backend {
});
}
- async _onApiGetEnvironmentInfo() {
- const browser = await Backend._getBrowser();
- const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
- return {
- browser,
- platform: {
- os: platform.os
- }
- };
+ _onApiGetEnvironmentInfo() {
+ return this.environment.getInfo();
}
async _onApiClipboardGet() {
@@ -663,7 +658,7 @@ class Backend {
being an extension with clipboard permissions. It effectively asks for the
non-extension permission for clipboard access.
*/
- const browser = await Backend._getBrowser();
+ const {browser} = this.environment.getInfo();
if (browser === 'firefox' || browser === 'firefox-mobile') {
return await navigator.clipboard.readText();
} else {
@@ -714,16 +709,165 @@ class Backend {
});
}
- _onApiGetMessageToken() {
- return this.messageToken;
- }
-
_onApiGetDefaultAnkiFieldTemplates() {
return this.defaultAnkiFieldTemplates;
}
+ async _onApiGetAnkiDeckNames() {
+ return await this.anki.getDeckNames();
+ }
+
+ async _onApiGetAnkiModelNames() {
+ return await this.anki.getModelNames();
+ }
+
+ async _onApiGetAnkiModelFieldNames({modelName}) {
+ return await this.anki.getModelFieldNames(modelName);
+ }
+
+ async _onApiGetDictionaryInfo() {
+ return await this.translator.database.getDictionaryInfo();
+ }
+
+ async _onApiGetDictionaryCounts({dictionaryNames, getTotal}) {
+ return await this.translator.database.getDictionaryCounts(dictionaryNames, getTotal);
+ }
+
+ async _onApiPurgeDatabase() {
+ this.translator.clearDatabaseCaches();
+ await this.database.purge();
+ }
+
+ async _onApiGetMedia({targets}) {
+ return await this.database.getMedia(targets);
+ }
+
+ _onApiLog({error, level, context}) {
+ yomichan.log(jsonToError(error), level, context);
+
+ const levelValue = this._getErrorLevelValue(level);
+ if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; }
+
+ this._logErrorLevel = level;
+ this._updateBadge();
+ }
+
+ _onApiLogIndicatorClear() {
+ if (this._logErrorLevel === null) { return; }
+ this._logErrorLevel = null;
+ this._updateBadge();
+ }
+
+ _onApiCreateActionPort(params, sender) {
+ if (!sender || !sender.tab) { throw new Error('Invalid sender'); }
+ const tabId = sender.tab.id;
+ if (typeof tabId !== 'number') { throw new Error('Sender has invalid tab ID'); }
+
+ const frameId = sender.frameId;
+ const id = yomichan.generateId(16);
+ const portName = `action-port-${id}`;
+
+ const port = chrome.tabs.connect(tabId, {name: portName, frameId});
+ try {
+ this._createActionListenerPort(port, sender, this._messageHandlersWithProgress);
+ } catch (e) {
+ port.disconnect();
+ throw e;
+ }
+
+ return portName;
+ }
+
+ async _onApiImportDictionaryArchive({archiveContent, details}, sender, onProgress) {
+ return await this.dictionaryImporter.import(this.database, archiveContent, details, onProgress);
+ }
+
+ async _onApiDeleteDictionary({dictionaryName}, sender, onProgress) {
+ this.translator.clearDatabaseCaches();
+ await this.database.deleteDictionary(dictionaryName, {rate: 1000}, onProgress);
+ }
+
+ async _onApiModifySettings({targets, source}) {
+ const results = [];
+ for (const target of targets) {
+ try {
+ this._modifySetting(target);
+ results.push({result: true});
+ } catch (e) {
+ results.push({error: errorToJson(e)});
+ }
+ }
+ await this._onApiOptionsSave({source});
+ return results;
+ }
+
// Command handlers
+ _createActionListenerPort(port, sender, handlers) {
+ let hasStarted = false;
+
+ const onProgress = (...data) => {
+ try {
+ if (port === null) { return; }
+ port.postMessage({type: 'progress', data});
+ } catch (e) {
+ // NOP
+ }
+ };
+
+ const onMessage = async ({action, params}) => {
+ if (hasStarted) { return; }
+ hasStarted = true;
+ port.onMessage.removeListener(onMessage);
+
+ try {
+ port.postMessage({type: 'ack'});
+
+ const messageHandler = handlers.get(action);
+ if (typeof messageHandler === 'undefined') {
+ throw new Error('Invalid action');
+ }
+ const {handler, async, contentScript} = messageHandler;
+
+ if (!contentScript) {
+ this._validatePrivilegedMessageSender(sender);
+ }
+
+ const promiseOrResult = handler(params, sender, onProgress);
+ const result = async ? await promiseOrResult : promiseOrResult;
+ port.postMessage({type: 'complete', data: result});
+ } catch (e) {
+ if (port !== null) {
+ port.postMessage({type: 'error', data: errorToJson(e)});
+ }
+ cleanup();
+ }
+ };
+
+ const cleanup = () => {
+ if (port === null) { return; }
+ if (!hasStarted) {
+ port.onMessage.removeListener(onMessage);
+ }
+ port.onDisconnect.removeListener(cleanup);
+ port = null;
+ handlers = null;
+ };
+
+ port.onMessage.addListener(onMessage);
+ port.onDisconnect.addListener(cleanup);
+ }
+
+ _getErrorLevelValue(errorLevel) {
+ switch (errorLevel) {
+ case 'info': return 0;
+ case 'debug': return 0;
+ case 'warn': return 1;
+ case 'error': return 2;
+ default: return 0;
+ }
+ }
+
async _onCommandSearch(params) {
const {mode='existingOrNewTab', query} = params || {};
@@ -748,7 +892,9 @@ class Backend {
await Backend._focusTab(tab);
if (queryParams.query) {
await new Promise((resolve) => chrome.tabs.sendMessage(
- tab.id, {action: 'searchQueryUpdate', params: {text: queryParams.query}}, resolve
+ tab.id,
+ {action: 'searchQueryUpdate', params: {text: queryParams.query}},
+ resolve
));
}
return true;
@@ -818,20 +964,163 @@ class Backend {
// Utilities
- async _getAudioUri(definition, source, details) {
- let optionsContext = (typeof details === 'object' && details !== null ? details.optionsContext : null);
- if (!(typeof optionsContext === 'object' && optionsContext !== null)) {
- optionsContext = this.optionsContext;
+ _getModifySettingObject(target) {
+ const scope = target.scope;
+ switch (scope) {
+ case 'profile':
+ if (!isObject(target.optionsContext)) { throw new Error('Invalid optionsContext'); }
+ return this.getOptions(target.optionsContext, true);
+ case 'global':
+ return this.getFullOptions(true);
+ default:
+ throw new Error(`Invalid scope: ${scope}`);
}
+ }
- const options = this.getOptions(optionsContext);
- return await this.audioUriBuilder.getUri(definition, source, options);
+ async _modifySetting(target) {
+ const options = this._getModifySettingObject(target);
+ const accessor = new ObjectPropertyAccessor(options);
+ const action = target.action;
+ switch (action) {
+ case 'set':
+ {
+ const {path, value} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ accessor.set(ObjectPropertyAccessor.getPathArray(path), value);
+ }
+ break;
+ case 'delete':
+ {
+ const {path} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ accessor.delete(ObjectPropertyAccessor.getPathArray(path));
+ }
+ break;
+ case 'swap':
+ {
+ const {path1, path2} = target;
+ if (typeof path1 !== 'string') { throw new Error('Invalid path1'); }
+ if (typeof path2 !== 'string') { throw new Error('Invalid path2'); }
+ accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2));
+ }
+ break;
+ case 'splice':
+ {
+ const {path, start, deleteCount, items} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); }
+ if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); }
+ if (!Array.isArray(items)) { throw new Error('Invalid items'); }
+ const array = accessor.get(ObjectPropertyAccessor.getPathArray(path));
+ if (!Array.isArray(array)) { throw new Error('Invalid target type'); }
+ array.splice(start, deleteCount, ...items);
+ }
+ break;
+ default:
+ throw new Error(`Unknown action: ${action}`);
+ }
+ }
+
+ _validatePrivilegedMessageSender(sender) {
+ const url = sender.url;
+ if (!(typeof url === 'string' && yomichan.isExtensionUrl(url))) {
+ throw new Error('Invalid message sender');
+ }
+ }
+
+ _getBrowserIconTitle() {
+ return (
+ isObject(chrome.browserAction) &&
+ typeof chrome.browserAction.getTitle === 'function' ?
+ new Promise((resolve) => chrome.browserAction.getTitle({}, resolve)) :
+ Promise.resolve('')
+ );
+ }
+
+ _updateBadge() {
+ let title = this._defaultBrowserActionTitle;
+ if (title === null || !isObject(chrome.browserAction)) {
+ // Not ready or invalid
+ return;
+ }
+
+ let text = '';
+ let color = null;
+ let status = null;
+
+ if (this._logErrorLevel !== null) {
+ switch (this._logErrorLevel) {
+ case 'error':
+ text = '!!';
+ color = '#f04e4e';
+ status = 'Error';
+ break;
+ default: // 'warn'
+ text = '!';
+ color = '#f0ad4e';
+ status = 'Warning';
+ break;
+ }
+ } else if (!this._isPrepared) {
+ if (this._prepareError) {
+ text = '!!';
+ color = '#f04e4e';
+ status = 'Error';
+ } else if (this._badgePrepareDelayTimer === null) {
+ text = '...';
+ color = '#f0ad4e';
+ status = 'Loading';
+ }
+ } else if (!this._anyOptionsMatches((options) => options.general.enable)) {
+ text = 'off';
+ color = '#555555';
+ status = 'Disabled';
+ } else if (!this._anyOptionsMatches((options) => this._isAnyDictionaryEnabled(options))) {
+ text = '!';
+ color = '#f0ad4e';
+ status = 'No dictionaries installed';
+ }
+
+ if (color !== null && typeof chrome.browserAction.setBadgeBackgroundColor === 'function') {
+ chrome.browserAction.setBadgeBackgroundColor({color});
+ }
+ if (text !== null && typeof chrome.browserAction.setBadgeText === 'function') {
+ chrome.browserAction.setBadgeText({text});
+ }
+ if (typeof chrome.browserAction.setTitle === 'function') {
+ if (status !== null) {
+ title = `${title} - ${status}`;
+ }
+ chrome.browserAction.setTitle({title});
+ }
+ }
+
+ _isAnyDictionaryEnabled(options) {
+ for (const {enabled} of Object.values(options.dictionaries)) {
+ if (enabled) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ _anyOptionsMatches(predicate) {
+ for (const {options} of this.options.profiles) {
+ const value = predicate(options);
+ if (value) { return value; }
+ }
+ return false;
}
async _renderTemplate(template, data) {
return handlebarsRenderDynamic(template, data);
}
+ _getTemplates(options) {
+ const templates = options.anki.fieldTemplates;
+ return typeof templates === 'string' ? templates : this.defaultAnkiFieldTemplates;
+ }
+
static _getTabUrl(tab) {
return new Promise((resolve) => {
chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => {
@@ -921,26 +1210,4 @@ class Backend {
// Edge throws exception for no reason here.
}
}
-
- static async _getBrowser() {
- if (EXTENSION_IS_BROWSER_EDGE) {
- return 'edge';
- }
- if (typeof browser !== 'undefined') {
- try {
- const info = await browser.runtime.getBrowserInfo();
- if (info.name === 'Fennec') {
- return 'firefox-mobile';
- }
- } catch (e) {
- // NOP
- }
- return 'firefox';
- } else {
- return 'chrome';
- }
- }
}
-
-window.yomichanBackend = new Backend();
-window.yomichanBackend.prepare();
diff --git a/ext/bg/js/background-main.js b/ext/bg/js/background-main.js
new file mode 100644
index 00000000..24117f4e
--- /dev/null
+++ b/ext/bg/js/background-main.js
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * Backend
+ */
+
+(async () => {
+ window.yomichanBackend = new Backend();
+ await window.yomichanBackend.prepare();
+})();
diff --git a/ext/bg/js/conditions.js b/ext/bg/js/conditions.js
index eb9582df..3f3c0a45 100644
--- a/ext/bg/js/conditions.js
+++ b/ext/bg/js/conditions.js
@@ -32,7 +32,15 @@ function conditionsValidateOptionValue(object, value) {
return value;
}
-function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue) {
+function conditionsValidateOptionInputValue(object, value) {
+ if (hasOwn(object, 'transformInput')) {
+ return object.transformInput(value);
+ }
+
+ return null;
+}
+
+function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue, isInput) {
if (!hasOwn(descriptors, type)) {
throw new Error('Invalid type');
}
@@ -44,13 +52,34 @@ function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue
const operatorDescriptor = conditionDescriptor.operators[operator];
- let transformedValue = conditionsValidateOptionValue(conditionDescriptor, optionValue);
- transformedValue = conditionsValidateOptionValue(operatorDescriptor, transformedValue);
+ const descriptorArray = [conditionDescriptor, operatorDescriptor];
+
+ let transformedValue = optionValue;
+
+ let inputTransformedValue = null;
+ if (isInput) {
+ for (const descriptor of descriptorArray) {
+ let value = inputTransformedValue !== null ? inputTransformedValue : transformedValue;
+ value = conditionsValidateOptionInputValue(descriptor, value);
+ if (value !== null) {
+ inputTransformedValue = value;
+ }
+ }
+
+ if (inputTransformedValue !== null) {
+ transformedValue = inputTransformedValue;
+ }
+ }
+
+ for (const descriptor of descriptorArray) {
+ transformedValue = conditionsValidateOptionValue(descriptor, transformedValue);
+ }
if (hasOwn(operatorDescriptor, 'transformReverse')) {
transformedValue = operatorDescriptor.transformReverse(transformedValue);
}
- return transformedValue;
+
+ return [transformedValue, inputTransformedValue];
}
function conditionsTestValueThrowing(descriptors, type, operator, optionValue, value) {
diff --git a/ext/bg/js/context.js b/ext/bg/js/context-main.js
index e3d4ad4a..dbba0272 100644
--- a/ext/bg/js/context.js
+++ b/ext/bg/js/context-main.js
@@ -17,7 +17,9 @@
/* global
* apiCommandExec
+ * apiForwardLogsToBackend
* apiGetEnvironmentInfo
+ * apiLogIndicatorClear
* apiOptionsGet
*/
@@ -51,9 +53,12 @@ function setupButtonEvents(selector, command, url) {
}
}
-window.addEventListener('DOMContentLoaded', async () => {
+async function mainInner() {
+ apiForwardLogsToBackend();
await yomichan.prepare();
+ await apiLogIndicatorClear();
+
showExtensionInfo();
apiGetEnvironmentInfo().then(({browser}) => {
@@ -86,4 +91,8 @@ window.addEventListener('DOMContentLoaded', async () => {
}
}, 10);
});
-});
+}
+
+(async () => {
+ window.addEventListener('DOMContentLoaded', mainInner, false);
+})();
diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js
index 260c815a..930cd0d0 100644
--- a/ext/bg/js/database.js
+++ b/ext/bg/js/database.js
@@ -33,7 +33,7 @@ class Database {
}
try {
- this.db = await Database._open('dict', 5, (db, transaction, oldVersion) => {
+ this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => {
Database._upgrade(db, transaction, oldVersion, [
{
version: 2,
@@ -90,12 +90,21 @@ class Database {
indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse']
}
}
+ },
+ {
+ version: 6,
+ stores: {
+ media: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'path']
+ }
+ }
}
]);
});
return true;
} catch (e) {
- logError(e);
+ yomichan.logError(e);
return false;
}
}
@@ -120,7 +129,7 @@ class Database {
await this.prepare();
}
- async deleteDictionary(dictionaryName, onProgress, progressSettings) {
+ async deleteDictionary(dictionaryName, progressSettings, onProgress) {
this._validate();
const targets = [
@@ -268,6 +277,34 @@ class Database {
return result;
}
+ async getMedia(targets) {
+ this._validate();
+
+ const count = targets.length;
+ const promises = [];
+ const results = new Array(count).fill(null);
+ const createResult = Database._createMedia;
+ const processRow = (row, [index, dictionaryName]) => {
+ if (row.dictionary === dictionaryName) {
+ results[index] = createResult(row, index);
+ }
+ };
+
+ const transaction = this.db.transaction(['media'], 'readonly');
+ const objectStore = transaction.objectStore('media');
+ const index = objectStore.index('path');
+
+ for (let i = 0; i < count; ++i) {
+ const {path, dictionaryName} = targets[i];
+ const only = IDBKeyRange.only(path);
+ promises.push(Database._getAll(index, only, [i, dictionaryName], processRow));
+ }
+
+ await Promise.all(promises);
+
+ return results;
+ }
+
async getDictionaryInfo() {
this._validate();
@@ -432,6 +469,10 @@ class Database {
return {character, mode, data, dictionary, index};
}
+ static _createMedia(row, index) {
+ return Object.assign({}, row, {index});
+ }
+
static _getAll(dbIndex, query, context, processRow) {
const fn = typeof dbIndex.getAll === 'function' ? Database._getAllFast : Database._getAllUsingCursor;
return fn(dbIndex, query, context, processRow);
diff --git a/ext/bg/js/dictionary-importer.js b/ext/bg/js/dictionary-importer.js
index bf6809ec..10e30cec 100644
--- a/ext/bg/js/dictionary-importer.js
+++ b/ext/bg/js/dictionary-importer.js
@@ -18,6 +18,7 @@
/* global
* JSZip
* JsonSchema
+ * mediaUtility
* requestJson
*/
@@ -26,7 +27,7 @@ class DictionaryImporter {
this._schemas = new Map();
}
- async import(database, archiveSource, onProgress, details) {
+ async import(database, archiveSource, details, onProgress) {
if (!database) {
throw new Error('Invalid database');
}
@@ -148,6 +149,22 @@ class DictionaryImporter {
}
}
+ // Extended data support
+ const extendedDataContext = {
+ archive,
+ media: new Map()
+ };
+ for (const entry of termList) {
+ const glossaryList = entry.glossary;
+ for (let i = 0, ii = glossaryList.length; i < ii; ++i) {
+ const glossary = glossaryList[i];
+ if (typeof glossary !== 'object' || glossary === null) { continue; }
+ glossaryList[i] = await this._formatDictionaryTermGlossaryObject(glossary, extendedDataContext, entry);
+ }
+ }
+
+ const media = [...extendedDataContext.media.values()];
+
// Add dictionary
const summary = this._createSummary(dictionaryTitle, version, index, {prefixWildcardsSupported});
@@ -188,6 +205,7 @@ class DictionaryImporter {
await bulkAdd('kanji', kanjiList);
await bulkAdd('kanjiMeta', kanjiMetaList);
await bulkAdd('tagMeta', tagList);
+ await bulkAdd('media', media);
return {result: summary, errors};
}
@@ -275,4 +293,76 @@ class DictionaryImporter {
return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank];
}
+
+ async _formatDictionaryTermGlossaryObject(data, context, entry) {
+ switch (data.type) {
+ case 'text':
+ return data.text;
+ case 'image':
+ return await this._formatDictionaryTermGlossaryImage(data, context, entry);
+ default:
+ throw new Error(`Unhandled data type: ${data.type}`);
+ }
+ }
+
+ async _formatDictionaryTermGlossaryImage(data, context, entry) {
+ const dictionary = entry.dictionary;
+ const {path, width: preferredWidth, height: preferredHeight, title, description, pixelated} = data;
+ if (context.media.has(path)) {
+ // Already exists
+ return data;
+ }
+
+ let errorSource = entry.expression;
+ if (entry.reading.length > 0) {
+ errorSource += ` (${entry.reading});`;
+ }
+
+ const file = context.archive.file(path);
+ if (file === null) {
+ throw new Error(`Could not find image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ const content = await file.async('base64');
+ const mediaType = mediaUtility.getImageMediaTypeFromFileName(path);
+ if (mediaType === null) {
+ throw new Error(`Could not determine media type for image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ let image;
+ try {
+ image = await mediaUtility.loadImageBase64(mediaType, content);
+ } catch (e) {
+ throw new Error(`Could not load image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ const width = image.naturalWidth;
+ const height = image.naturalHeight;
+
+ // Create image data
+ const mediaData = {
+ dictionary,
+ path,
+ mediaType,
+ width,
+ height,
+ content
+ };
+ context.media.set(path, mediaData);
+
+ // Create new data
+ const newData = {
+ type: 'image',
+ path,
+ width,
+ height
+ };
+ if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; }
+ if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; }
+ if (typeof title === 'string') { newData.title = title; }
+ if (typeof description === 'string') { newData.description = description; }
+ if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; }
+
+ return newData;
+ }
}
diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js
index 860acb14..822174e2 100644
--- a/ext/bg/js/handlebars.js
+++ b/ext/bg/js/handlebars.js
@@ -123,6 +123,26 @@ function handlebarsRegexMatch(...args) {
return value;
}
+function handlebarsMergeTags(object, isGroupMode, isMergeMode) {
+ const tagSources = [];
+ if (isGroupMode || isMergeMode) {
+ for (const definition of object.definitions) {
+ tagSources.push(definition.definitionTags);
+ }
+ } else {
+ tagSources.push(object.definitionTags);
+ }
+
+ const tags = new Set();
+ for (const tagSource of tagSources) {
+ for (const tag of tagSource) {
+ tags.add(tag.name);
+ }
+ }
+
+ return [...tags].join(', ');
+}
+
function handlebarsRegisterHelpers() {
if (Handlebars.partials !== Handlebars.templates) {
Handlebars.partials = Handlebars.templates;
@@ -134,6 +154,7 @@ function handlebarsRegisterHelpers() {
Handlebars.registerHelper('sanitizeCssClass', handlebarsSanitizeCssClass);
Handlebars.registerHelper('regexReplace', handlebarsRegexReplace);
Handlebars.registerHelper('regexMatch', handlebarsRegexMatch);
+ Handlebars.registerHelper('mergeTags', handlebarsMergeTags);
}
}
diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js
deleted file mode 100644
index ac81acb5..00000000
--- a/ext/bg/js/japanese.js
+++ /dev/null
@@ -1,426 +0,0 @@
-/*
- * Copyright (C) 2016-2020 Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * jp
- * wanakana
- */
-
-(() => {
- const HALFWIDTH_KATAKANA_MAPPING = new Map([
- ['ヲ', 'ヲヺ-'],
- ['ァ', 'ァ--'],
- ['ィ', 'ィ--'],
- ['ゥ', 'ゥ--'],
- ['ェ', 'ェ--'],
- ['ォ', 'ォ--'],
- ['ャ', 'ャ--'],
- ['ュ', 'ュ--'],
- ['ョ', 'ョ--'],
- ['ッ', 'ッ--'],
- ['ー', 'ー--'],
- ['ア', 'ア--'],
- ['イ', 'イ--'],
- ['ウ', 'ウヴ-'],
- ['エ', 'エ--'],
- ['オ', 'オ--'],
- ['カ', 'カガ-'],
- ['キ', 'キギ-'],
- ['ク', 'クグ-'],
- ['ケ', 'ケゲ-'],
- ['コ', 'コゴ-'],
- ['サ', 'サザ-'],
- ['シ', 'シジ-'],
- ['ス', 'スズ-'],
- ['セ', 'セゼ-'],
- ['ソ', 'ソゾ-'],
- ['タ', 'タダ-'],
- ['チ', 'チヂ-'],
- ['ツ', 'ツヅ-'],
- ['テ', 'テデ-'],
- ['ト', 'トド-'],
- ['ナ', 'ナ--'],
- ['ニ', 'ニ--'],
- ['ヌ', 'ヌ--'],
- ['ネ', 'ネ--'],
- ['ノ', 'ノ--'],
- ['ハ', 'ハバパ'],
- ['ヒ', 'ヒビピ'],
- ['フ', 'フブプ'],
- ['ヘ', 'ヘベペ'],
- ['ホ', 'ホボポ'],
- ['マ', 'マ--'],
- ['ミ', 'ミ--'],
- ['ム', 'ム--'],
- ['メ', 'メ--'],
- ['モ', 'モ--'],
- ['ヤ', 'ヤ--'],
- ['ユ', 'ユ--'],
- ['ヨ', 'ヨ--'],
- ['ラ', 'ラ--'],
- ['リ', 'リ--'],
- ['ル', 'ル--'],
- ['レ', 'レ--'],
- ['ロ', 'ロ--'],
- ['ワ', 'ワ--'],
- ['ン', 'ン--']
- ]);
-
- const ITERATION_MARK_CODE_POINT = 0x3005;
-
- const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063;
- const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3;
- const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
-
- // Existing functions
-
- const isCodePointKanji = jp.isCodePointKanji;
- const isStringEntirelyKana = jp.isStringEntirelyKana;
-
-
- // Conversion functions
-
- function convertKatakanaToHiragana(text) {
- let result = '';
- for (const c of text) {
- if (wanakana.isKatakana(c)) {
- result += wanakana.toHiragana(c);
- } else {
- result += c;
- }
- }
-
- return result;
- }
-
- function convertHiraganaToKatakana(text) {
- let result = '';
- for (const c of text) {
- if (wanakana.isHiragana(c)) {
- result += wanakana.toKatakana(c);
- } else {
- result += c;
- }
- }
-
- return result;
- }
-
- function convertToRomaji(text) {
- return wanakana.toRomaji(text);
- }
-
- function convertReading(expression, reading, readingMode) {
- switch (readingMode) {
- case 'hiragana':
- return convertKatakanaToHiragana(reading);
- case 'katakana':
- return convertHiraganaToKatakana(reading);
- case 'romaji':
- if (reading) {
- return convertToRomaji(reading);
- } else {
- if (isStringEntirelyKana(expression)) {
- return convertToRomaji(expression);
- }
- }
- return reading;
- case 'none':
- return '';
- default:
- return reading;
- }
- }
-
- function convertNumericToFullWidth(text) {
- let result = '';
- for (const char of text) {
- let c = char.codePointAt(0);
- if (c >= 0x30 && c <= 0x39) { // ['0', '9']
- c += 0xff10 - 0x30; // 0xff10 = '0' full width
- result += String.fromCodePoint(c);
- } else {
- result += char;
- }
- }
- return result;
- }
-
- function convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
- let result = '';
-
- // This function is safe to use charCodeAt instead of codePointAt, since all
- // the relevant characters are represented with a single UTF-16 character code.
- for (let i = 0, ii = text.length; i < ii; ++i) {
- const c = text[i];
- const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c);
- if (typeof mapping !== 'string') {
- result += c;
- continue;
- }
-
- let index = 0;
- switch (text.charCodeAt(i + 1)) {
- case 0xff9e: // dakuten
- index = 1;
- break;
- case 0xff9f: // handakuten
- index = 2;
- break;
- }
-
- let c2 = mapping[index];
- if (index > 0) {
- if (c2 === '-') { // invalid
- index = 0;
- c2 = mapping[0];
- } else {
- ++i;
- }
- }
-
- if (sourceMap !== null && index > 0) {
- sourceMap.combine(result.length, 1);
- }
- result += c2;
- }
-
- return result;
- }
-
- function convertAlphabeticToKana(text, sourceMap=null) {
- let part = '';
- let result = '';
-
- for (const char of text) {
- // Note: 0x61 is the character code for 'a'
- let c = char.codePointAt(0);
- if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
- c += (0x61 - 0x41);
- } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
- // NOP; c += (0x61 - 0x61);
- } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
- c += (0x61 - 0xff21);
- } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
- c += (0x61 - 0xff41);
- } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
- c = 0x2d; // '-'
- } else {
- if (part.length > 0) {
- result += convertAlphabeticPartToKana(part, sourceMap, result.length);
- part = '';
- }
- result += char;
- continue;
- }
- part += String.fromCodePoint(c);
- }
-
- if (part.length > 0) {
- result += convertAlphabeticPartToKana(part, sourceMap, result.length);
- }
- return result;
- }
-
- function convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) {
- const result = wanakana.toHiragana(text);
-
- // Generate source mapping
- if (sourceMap !== null) {
- let i = 0;
- let resultPos = 0;
- const ii = text.length;
- while (i < ii) {
- // Find smallest matching substring
- let iNext = i + 1;
- let resultPosNext = result.length;
- while (iNext < ii) {
- const t = wanakana.toHiragana(text.substring(0, iNext));
- if (t === result.substring(0, t.length)) {
- resultPosNext = t.length;
- break;
- }
- ++iNext;
- }
-
- // Merge characters
- const removals = iNext - i - 1;
- if (removals > 0) {
- sourceMap.combine(sourceMapStart, removals);
- }
- ++sourceMapStart;
-
- // Empty elements
- const additions = resultPosNext - resultPos - 1;
- for (let j = 0; j < additions; ++j) {
- sourceMap.insert(sourceMapStart, 0);
- ++sourceMapStart;
- }
-
- i = iNext;
- resultPos = resultPosNext;
- }
- }
-
- return result;
- }
-
-
- // Furigana distribution
-
- function distributeFurigana(expression, reading) {
- const fallback = [{furigana: reading, text: expression}];
- if (!reading) {
- return fallback;
- }
-
- let isAmbiguous = false;
- const segmentize = (reading2, groups) => {
- if (groups.length === 0 || isAmbiguous) {
- return [];
- }
-
- const group = groups[0];
- if (group.mode === 'kana') {
- if (convertKatakanaToHiragana(reading2).startsWith(convertKatakanaToHiragana(group.text))) {
- const readingLeft = reading2.substring(group.text.length);
- const segs = segmentize(readingLeft, groups.splice(1));
- if (segs) {
- return [{text: group.text, furigana: ''}].concat(segs);
- }
- }
- } else {
- let foundSegments = null;
- for (let i = reading2.length; i >= group.text.length; --i) {
- const readingUsed = reading2.substring(0, i);
- const readingLeft = reading2.substring(i);
- const segs = segmentize(readingLeft, groups.slice(1));
- if (segs) {
- if (foundSegments !== null) {
- // more than one way to segmentize the tail, mark as ambiguous
- isAmbiguous = true;
- return null;
- }
- foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs);
- }
- // there is only one way to segmentize the last non-kana group
- if (groups.length === 1) {
- break;
- }
- }
- return foundSegments;
- }
- };
-
- const groups = [];
- let modePrev = null;
- for (const c of expression) {
- const codePoint = c.codePointAt(0);
- const modeCurr = isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT ? 'kanji' : 'kana';
- if (modeCurr === modePrev) {
- groups[groups.length - 1].text += c;
- } else {
- groups.push({mode: modeCurr, text: c});
- modePrev = modeCurr;
- }
- }
-
- const segments = segmentize(reading, groups);
- if (segments && !isAmbiguous) {
- return segments;
- }
- return fallback;
- }
-
- function distributeFuriganaInflected(expression, reading, source) {
- const output = [];
-
- let stemLength = 0;
- const shortest = Math.min(source.length, expression.length);
- const sourceHiragana = convertKatakanaToHiragana(source);
- const expressionHiragana = convertKatakanaToHiragana(expression);
- while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) {
- ++stemLength;
- }
- const offset = source.length - stemLength;
-
- const stemExpression = source.substring(0, source.length - offset);
- const stemReading = reading.substring(
- 0,
- offset === 0 ? reading.length : reading.length - expression.length + stemLength
- );
- for (const segment of distributeFurigana(stemExpression, stemReading)) {
- output.push(segment);
- }
-
- if (stemLength !== source.length) {
- output.push({text: source.substring(stemLength), furigana: ''});
- }
-
- return output;
- }
-
-
- // Miscellaneous
-
- function collapseEmphaticSequences(text, fullCollapse, sourceMap=null) {
- let result = '';
- let collapseCodePoint = -1;
- const hasSourceMap = (sourceMap !== null);
- for (const char of text) {
- const c = char.codePointAt(0);
- if (
- c === HIRAGANA_SMALL_TSU_CODE_POINT ||
- c === KATAKANA_SMALL_TSU_CODE_POINT ||
- c === KANA_PROLONGED_SOUND_MARK_CODE_POINT
- ) {
- if (collapseCodePoint !== c) {
- collapseCodePoint = c;
- if (!fullCollapse) {
- result += char;
- continue;
- }
- }
- } else {
- collapseCodePoint = -1;
- result += char;
- continue;
- }
-
- if (hasSourceMap) {
- sourceMap.combine(Math.max(0, result.length - 1), 1);
- }
- }
- return result;
- }
-
-
- // Exports
-
- Object.assign(jp, {
- convertKatakanaToHiragana,
- convertHiraganaToKatakana,
- convertToRomaji,
- convertReading,
- convertNumericToFullWidth,
- convertHalfWidthKanaToFullWidth,
- convertAlphabeticToKana,
- distributeFurigana,
- distributeFuriganaInflected,
- collapseEmphaticSequences
- });
-})();
diff --git a/ext/bg/js/mecab.js b/ext/bg/js/mecab.js
index 597dceae..815ee860 100644
--- a/ext/bg/js/mecab.js
+++ b/ext/bg/js/mecab.js
@@ -24,7 +24,7 @@ class Mecab {
}
onError(error) {
- logError(error, false);
+ yomichan.logError(error);
}
async checkVersion() {
diff --git a/ext/bg/js/media-utility.js b/ext/bg/js/media-utility.js
new file mode 100644
index 00000000..1f93b2b4
--- /dev/null
+++ b/ext/bg/js/media-utility.js
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/**
+ * mediaUtility is an object containing helper methods related to media processing.
+ */
+const mediaUtility = (() => {
+ /**
+ * Gets the file extension of a file path. URL search queries and hash
+ * fragments are not handled.
+ * @param path The path to the file.
+ * @returns The file extension, including the '.', or an empty string
+ * if there is no file extension.
+ */
+ function getFileNameExtension(path) {
+ const match = /\.[^./\\]*$/.exec(path);
+ return match !== null ? match[0] : '';
+ }
+
+ /**
+ * Gets an image file's media type using a file path.
+ * @param path The path to the file.
+ * @returns The media type string if it can be determined from the file path,
+ * otherwise null.
+ */
+ function getImageMediaTypeFromFileName(path) {
+ switch (getFileNameExtension(path).toLowerCase()) {
+ case '.apng':
+ return 'image/apng';
+ case '.bmp':
+ return 'image/bmp';
+ case '.gif':
+ return 'image/gif';
+ case '.ico':
+ case '.cur':
+ return 'image/x-icon';
+ case '.jpg':
+ case '.jpeg':
+ case '.jfif':
+ case '.pjpeg':
+ case '.pjp':
+ return 'image/jpeg';
+ case '.png':
+ return 'image/png';
+ case '.svg':
+ return 'image/svg+xml';
+ case '.tif':
+ case '.tiff':
+ return 'image/tiff';
+ case '.webp':
+ return 'image/webp';
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Attempts to load an image using a base64 encoded content and a media type.
+ * @param mediaType The media type for the image content.
+ * @param content The binary content for the image, encoded in base64.
+ * @returns A Promise which resolves with an HTMLImageElement instance on
+ * successful load, otherwise an error is thrown.
+ */
+ function loadImageBase64(mediaType, content) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ const eventListeners = new EventListenerCollection();
+ eventListeners.addEventListener(image, 'load', () => {
+ eventListeners.removeAllEventListeners();
+ resolve(image);
+ }, false);
+ eventListeners.addEventListener(image, 'error', () => {
+ eventListeners.removeAllEventListeners();
+ reject(new Error('Image failed to load'));
+ }, false);
+ image.src = `data:${mediaType};base64,${content}`;
+ });
+ }
+
+ return {
+ getImageMediaTypeFromFileName,
+ loadImageBase64
+ };
+})();
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index f3e5f60d..35fdde82 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -15,14 +15,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-/* global
- * utilStringHashCode
- */
-
/*
* Generic options functions
*/
+function optionsGetStringHashCode(string) {
+ let hashCode = 0;
+
+ if (typeof string !== 'string') { return hashCode; }
+
+ for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) {
+ hashCode = ((hashCode << 5) - hashCode) + charCode;
+ hashCode |= 0;
+ }
+
+ return hashCode;
+}
+
function optionsGenericApplyUpdates(options, updates) {
const targetVersion = updates.length;
const currentVersion = options.version;
@@ -63,12 +72,12 @@ const profileOptionsVersionUpdates = [
options.anki.fieldTemplates = null;
},
(options) => {
- if (utilStringHashCode(options.anki.fieldTemplates) === 1285806040) {
+ if (optionsGetStringHashCode(options.anki.fieldTemplates) === 1285806040) {
options.anki.fieldTemplates = null;
}
},
(options) => {
- if (utilStringHashCode(options.anki.fieldTemplates) === -250091611) {
+ if (optionsGetStringHashCode(options.anki.fieldTemplates) === -250091611) {
options.anki.fieldTemplates = null;
}
},
@@ -87,7 +96,7 @@ const profileOptionsVersionUpdates = [
(options) => {
// Version 12 changes:
// The preferred default value of options.anki.fieldTemplates has been changed to null.
- if (utilStringHashCode(options.anki.fieldTemplates) === 1444379824) {
+ if (optionsGetStringHashCode(options.anki.fieldTemplates) === 1444379824) {
options.anki.fieldTemplates = null;
}
},
@@ -99,6 +108,37 @@ const profileOptionsVersionUpdates = [
fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}';
options.anki.fieldTemplates = fieldTemplates;
}
+ },
+ (options) => {
+ // Version 14 changes:
+ // Changed template for Anki audio and tags.
+ let fieldTemplates = options.anki.fieldTemplates;
+ if (typeof fieldTemplates !== 'string') { return; }
+
+ const replacements = [
+ [
+ '{{#*inline "audio"}}{{/inline}}',
+ '{{#*inline "audio"}}\n {{~#if definition.audioFileName~}}\n [sound:{{definition.audioFileName}}]\n {{~/if~}}\n{{/inline}}'
+ ],
+ [
+ '{{#*inline "tags"}}\n {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}\n{{/inline}}',
+ '{{#*inline "tags"}}\n {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}\n{{/inline}}'
+ ]
+ ];
+
+ for (const [pattern, replacement] of replacements) {
+ let replaced = false;
+ fieldTemplates = fieldTemplates.replace(new RegExp(escapeRegExp(pattern), 'g'), () => {
+ replaced = true;
+ return replacement;
+ });
+
+ if (!replaced) {
+ fieldTemplates += '\n\n' + replacement;
+ }
+ }
+
+ options.anki.fieldTemplates = fieldTemplates;
}
];
@@ -192,6 +232,7 @@ function profileOptionsCreateDefaults() {
screenshot: {format: 'png', quality: 92},
terms: {deck: '', model: '', fields: {}},
kanji: {deck: '', model: '', fields: {}},
+ duplicateScope: 'collection',
fieldTemplates: null
}
};
diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js
index a0710bd1..97e09f1c 100644
--- a/ext/bg/js/profile-conditions.js
+++ b/ext/bg/js/profile-conditions.js
@@ -15,6 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+/* global
+ * Environment
+ */
function _profileConditionTestDomain(urlDomain, domain) {
return (
@@ -36,69 +39,140 @@ function _profileConditionTestDomainList(url, domainList) {
return false;
}
-const profileConditionsDescriptor = {
- popupLevel: {
- name: 'Popup Level',
- description: 'Use profile depending on the level of the popup.',
- placeholder: 'Number',
- type: 'number',
- step: 1,
- defaultValue: 0,
- defaultOperator: 'equal',
- transform: (optionValue) => parseInt(optionValue, 10),
- transformReverse: (transformedOptionValue) => `${transformedOptionValue}`,
- validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue),
- operators: {
- equal: {
- name: '=',
- test: ({depth}, optionValue) => (depth === optionValue)
- },
- notEqual: {
- name: '\u2260',
- test: ({depth}, optionValue) => (depth !== optionValue)
- },
- lessThan: {
- name: '<',
- test: ({depth}, optionValue) => (depth < optionValue)
- },
- greaterThan: {
- name: '>',
- test: ({depth}, optionValue) => (depth > optionValue)
- },
- lessThanOrEqual: {
- name: '\u2264',
- test: ({depth}, optionValue) => (depth <= optionValue)
- },
- greaterThanOrEqual: {
- name: '\u2265',
- test: ({depth}, optionValue) => (depth >= optionValue)
+let profileConditionsDescriptor = null;
+
+const profileConditionsDescriptorPromise = (async () => {
+ const environment = new Environment();
+ await environment.prepare();
+
+ const modifiers = environment.getInfo().modifiers;
+ const modifierSeparator = modifiers.separator;
+ const modifierKeyValues = modifiers.keys.map(
+ ({value, name}) => ({optionValue: value, name})
+ );
+
+ const modifierValueToName = new Map(
+ modifierKeyValues.map(({optionValue, name}) => [optionValue, name])
+ );
+
+ const modifierNameToValue = new Map(
+ modifierKeyValues.map(({optionValue, name}) => [name, optionValue])
+ );
+
+ profileConditionsDescriptor = {
+ popupLevel: {
+ name: 'Popup Level',
+ description: 'Use profile depending on the level of the popup.',
+ placeholder: 'Number',
+ type: 'number',
+ step: 1,
+ defaultValue: 0,
+ defaultOperator: 'equal',
+ transform: (optionValue) => parseInt(optionValue, 10),
+ transformReverse: (transformedOptionValue) => `${transformedOptionValue}`,
+ validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue),
+ operators: {
+ equal: {
+ name: '=',
+ test: ({depth}, optionValue) => (depth === optionValue)
+ },
+ notEqual: {
+ name: '\u2260',
+ test: ({depth}, optionValue) => (depth !== optionValue)
+ },
+ lessThan: {
+ name: '<',
+ test: ({depth}, optionValue) => (depth < optionValue)
+ },
+ greaterThan: {
+ name: '>',
+ test: ({depth}, optionValue) => (depth > optionValue)
+ },
+ lessThanOrEqual: {
+ name: '\u2264',
+ test: ({depth}, optionValue) => (depth <= optionValue)
+ },
+ greaterThanOrEqual: {
+ name: '\u2265',
+ test: ({depth}, optionValue) => (depth >= optionValue)
+ }
}
- }
- },
- url: {
- name: 'URL',
- description: 'Use profile depending on the URL of the current website.',
- defaultOperator: 'matchDomain',
- operators: {
- matchDomain: {
- name: 'Matches Domain',
- placeholder: 'Comma separated list of domains',
- defaultValue: 'example.com',
- transformCache: {},
- transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0),
- transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '),
- validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0),
- test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue)
- },
- matchRegExp: {
- name: 'Matches RegExp',
- placeholder: 'Regular expression',
- defaultValue: 'example\\.com',
- transformCache: {},
- transform: (optionValue) => new RegExp(optionValue, 'i'),
- transformReverse: (transformedOptionValue) => transformedOptionValue.source,
- test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url))
+ },
+ url: {
+ name: 'URL',
+ description: 'Use profile depending on the URL of the current website.',
+ defaultOperator: 'matchDomain',
+ operators: {
+ matchDomain: {
+ name: 'Matches Domain',
+ placeholder: 'Comma separated list of domains',
+ defaultValue: 'example.com',
+ transformCache: {},
+ transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0),
+ transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '),
+ validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0),
+ test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue)
+ },
+ matchRegExp: {
+ name: 'Matches RegExp',
+ placeholder: 'Regular expression',
+ defaultValue: 'example\\.com',
+ transformCache: {},
+ transform: (optionValue) => new RegExp(optionValue, 'i'),
+ transformReverse: (transformedOptionValue) => transformedOptionValue.source,
+ test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url))
+ }
+ }
+ },
+ modifierKeys: {
+ name: 'Modifier Keys',
+ description: 'Use profile depending on the active modifier keys.',
+ values: modifierKeyValues,
+ defaultOperator: 'are',
+ operators: {
+ are: {
+ name: 'are',
+ placeholder: 'Press one or more modifier keys here',
+ defaultValue: [],
+ type: 'keyMulti',
+ keySeparator: modifierSeparator,
+ transformInput: (optionValue) => optionValue
+ .split(modifierSeparator)
+ .filter((v) => v.length > 0)
+ .map((v) => modifierNameToValue.get(v)),
+ transformReverse: (transformedOptionValue) => transformedOptionValue
+ .map((v) => modifierValueToName.get(v))
+ .join(modifierSeparator),
+ test: ({modifierKeys}, optionValue) => areSetsEqual(new Set(modifierKeys), new Set(optionValue))
+ },
+ areNot: {
+ name: 'are not',
+ placeholder: 'Press one or more modifier keys here',
+ defaultValue: [],
+ type: 'keyMulti',
+ keySeparator: modifierSeparator,
+ transformInput: (optionValue) => optionValue
+ .split(modifierSeparator)
+ .filter((v) => v.length > 0)
+ .map((v) => modifierNameToValue.get(v)),
+ transformReverse: (transformedOptionValue) => transformedOptionValue
+ .map((v) => modifierValueToName.get(v))
+ .join(modifierSeparator),
+ test: ({modifierKeys}, optionValue) => !areSetsEqual(new Set(modifierKeys), new Set(optionValue))
+ },
+ include: {
+ name: 'include',
+ type: 'select',
+ defaultValue: 'alt',
+ test: ({modifierKeys}, optionValue) => modifierKeys.includes(optionValue)
+ },
+ notInclude: {
+ name: 'don\'t include',
+ type: 'select',
+ defaultValue: 'alt',
+ test: ({modifierKeys}, optionValue) => !modifierKeys.includes(optionValue)
+ }
}
}
- }
-};
+ };
+})();
diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-main.js
index e534e771..54fa549d 100644
--- a/ext/bg/js/search-frontend.js
+++ b/ext/bg/js/search-main.js
@@ -16,64 +16,46 @@
*/
/* global
+ * DisplaySearch
+ * apiForwardLogsToBackend
* apiOptionsGet
+ * dynamicLoader
*/
-function injectSearchFrontend() {
- const scriptSrcs = [
+async function injectSearchFrontend() {
+ await dynamicLoader.loadScripts([
'/mixed/js/text-scanner.js',
'/fg/js/frontend-api-receiver.js',
'/fg/js/frame-offset-forwarder.js',
'/fg/js/popup.js',
- '/fg/js/popup-proxy-host.js',
+ '/fg/js/popup-factory.js',
'/fg/js/frontend.js',
- '/fg/js/frontend-initialize.js'
- ];
- for (const src of scriptSrcs) {
- const node = document.querySelector(`script[src='${src}']`);
- if (node !== null) { continue; }
-
- const script = document.createElement('script');
- script.async = false;
- script.src = src;
- document.body.appendChild(script);
- }
-
- const styleSrcs = [
- '/fg/css/client.css'
- ];
- for (const src of styleSrcs) {
- const style = document.createElement('link');
- style.rel = 'stylesheet';
- style.type = 'text/css';
- style.href = src;
- document.head.appendChild(style);
- }
+ '/fg/js/content-script-main.js'
+ ]);
}
-async function main() {
+(async () => {
+ apiForwardLogsToBackend();
await yomichan.prepare();
+ const displaySearch = new DisplaySearch();
+ await displaySearch.prepare();
+
let optionsApplied = false;
const applyOptions = async () => {
- const optionsContext = {
- depth: 0,
- url: window.location.href
- };
+ const optionsContext = {depth: 0, url: window.location.href};
const options = await apiOptionsGet(optionsContext);
if (!options.scanning.enableOnSearchPage || optionsApplied) { return; }
+
optionsApplied = true;
+ yomichan.off('optionsUpdated', applyOptions);
window.frontendInitializationData = {depth: 1, proxy: false, isSearchPage: true};
- injectSearchFrontend();
-
- yomichan.off('optionsUpdated', applyOptions);
+ await injectSearchFrontend();
};
yomichan.on('optionsUpdated', applyOptions);
await applyOptions();
-}
-
-main();
+})();
diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js
index eb3b681c..e1e37d1c 100644
--- a/ext/bg/js/search-query-parser.js
+++ b/ext/bg/js/search-query-parser.js
@@ -18,56 +18,76 @@
/* global
* QueryParserGenerator
* TextScanner
- * apiOptionsSet
+ * apiModifySettings
* apiTermsFind
* apiTextParse
* docSentenceExtract
*/
-class QueryParser extends TextScanner {
+class QueryParser {
constructor({getOptionsContext, setContent, setSpinnerVisible}) {
- super(document.querySelector('#query-parser-content'), () => [], []);
+ this._options = null;
+ this._getOptionsContext = getOptionsContext;
+ this._setContent = setContent;
+ this._setSpinnerVisible = setSpinnerVisible;
+ this._parseResults = [];
+ this._queryParser = document.querySelector('#query-parser-content');
+ this._queryParserSelect = document.querySelector('#query-parser-select-container');
+ this._queryParserGenerator = new QueryParserGenerator();
+ this._textScanner = new TextScanner({
+ node: this._queryParser,
+ ignoreElements: () => [],
+ ignorePoint: null,
+ search: this._search.bind(this)
+ });
+ }
- this.getOptionsContext = getOptionsContext;
- this.setContent = setContent;
- this.setSpinnerVisible = setSpinnerVisible;
+ async prepare() {
+ await this._queryParserGenerator.prepare();
+ this._queryParser.addEventListener('click', this._onClick.bind(this));
+ }
- this.parseResults = [];
+ setOptions(options) {
+ this._options = options;
+ this._textScanner.setOptions(options);
+ this._textScanner.setEnabled(true);
+ this._queryParser.dataset.termSpacing = `${options.parsing.termSpacing}`;
+ }
- this.queryParser = document.querySelector('#query-parser-content');
- this.queryParserSelect = document.querySelector('#query-parser-select-container');
+ async setText(text) {
+ this._setSpinnerVisible(true);
- this.queryParserGenerator = new QueryParserGenerator();
- }
+ this._setPreview(text);
- async prepare() {
- await this.queryParserGenerator.prepare();
- }
+ this._parseResults = await apiTextParse(text, this._getOptionsContext());
+ this._refreshSelectedParser();
+
+ this._renderParserSelect();
+ this._renderParseResult();
- onError(error) {
- logError(error, false);
+ this._setSpinnerVisible(false);
}
- onClick(e) {
- super.onClick(e);
- this.searchAt(e.clientX, e.clientY, 'click');
+ // Private
+
+ _onClick(e) {
+ this._textScanner.searchAt(e.clientX, e.clientY, 'click');
}
- async onSearchSource(textSource, cause) {
+ async _search(textSource, cause) {
if (textSource === null) { return null; }
- this.setTextSourceScanLength(textSource, this.options.scanning.length);
- const searchText = textSource.text();
- if (searchText.length === 0) { return; }
+ const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length);
+ if (searchText.length === 0) { return null; }
- const {definitions, length} = await apiTermsFind(searchText, {}, this.getOptionsContext());
+ const {definitions, length} = await apiTermsFind(searchText, {}, this._getOptionsContext());
if (definitions.length === 0) { return null; }
- const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
+ const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt);
textSource.setEndOffset(length);
- this.setContent('terms', {definitions, context: {
+ this._setContent('terms', {definitions, context: {
focus: false,
disableHistory: cause === 'mouse',
sentence,
@@ -77,89 +97,61 @@ class QueryParser extends TextScanner {
return {definitions, type: 'terms'};
}
- onParserChange(e) {
- const selectedParser = e.target.value;
- apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext());
- }
-
- getMouseEventListeners() {
- return [
- [this.node, 'click', this.onClick.bind(this)],
- [this.node, 'mousedown', this.onMouseDown.bind(this)],
- [this.node, 'mousemove', this.onMouseMove.bind(this)],
- [this.node, 'mouseover', this.onMouseOver.bind(this)],
- [this.node, 'mouseout', this.onMouseOut.bind(this)]
- ];
+ _onParserChange(e) {
+ const value = e.target.value;
+ apiModifySettings([{
+ action: 'set',
+ path: 'parsing.selectedParser',
+ value,
+ scope: 'profile',
+ optionsContext: this._getOptionsContext()
+ }], 'search');
}
- getTouchEventListeners() {
- return [
- [this.node, 'auxclick', this.onAuxClick.bind(this)],
- [this.node, 'touchstart', this.onTouchStart.bind(this)],
- [this.node, 'touchend', this.onTouchEnd.bind(this)],
- [this.node, 'touchcancel', this.onTouchCancel.bind(this)],
- [this.node, 'touchmove', this.onTouchMove.bind(this), {passive: false}],
- [this.node, 'contextmenu', this.onContextMenu.bind(this)]
- ];
- }
-
- setOptions(options) {
- super.setOptions(options);
- this.queryParser.dataset.termSpacing = `${options.parsing.termSpacing}`;
- }
-
- refreshSelectedParser() {
- if (this.parseResults.length > 0) {
- if (!this.getParseResult()) {
- const selectedParser = this.parseResults[0].id;
- apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext());
+ _refreshSelectedParser() {
+ if (this._parseResults.length > 0) {
+ if (!this._getParseResult()) {
+ const value = this._parseResults[0].id;
+ apiModifySettings([{
+ action: 'set',
+ path: 'parsing.selectedParser',
+ value,
+ scope: 'profile',
+ optionsContext: this._getOptionsContext()
+ }], 'search');
}
}
}
- getParseResult() {
- const {selectedParser} = this.options.parsing;
- return this.parseResults.find((r) => r.id === selectedParser);
- }
-
- async setText(text) {
- this.setSpinnerVisible(true);
-
- this.setPreview(text);
-
- this.parseResults = await apiTextParse(text, this.getOptionsContext());
- this.refreshSelectedParser();
-
- this.renderParserSelect();
- this.renderParseResult();
-
- this.setSpinnerVisible(false);
+ _getParseResult() {
+ const {selectedParser} = this._options.parsing;
+ return this._parseResults.find((r) => r.id === selectedParser);
}
- setPreview(text) {
+ _setPreview(text) {
const previewTerms = [];
for (let i = 0, ii = text.length; i < ii; i += 2) {
const tempText = text.substring(i, i + 2);
previewTerms.push([{text: tempText, reading: ''}]);
}
- this.queryParser.textContent = '';
- this.queryParser.appendChild(this.queryParserGenerator.createParseResult(previewTerms, true));
+ this._queryParser.textContent = '';
+ this._queryParser.appendChild(this._queryParserGenerator.createParseResult(previewTerms, true));
}
- renderParserSelect() {
- this.queryParserSelect.textContent = '';
- if (this.parseResults.length > 1) {
- const {selectedParser} = this.options.parsing;
- const select = this.queryParserGenerator.createParserSelect(this.parseResults, selectedParser);
- select.addEventListener('change', this.onParserChange.bind(this));
- this.queryParserSelect.appendChild(select);
+ _renderParserSelect() {
+ this._queryParserSelect.textContent = '';
+ if (this._parseResults.length > 1) {
+ const {selectedParser} = this._options.parsing;
+ const select = this._queryParserGenerator.createParserSelect(this._parseResults, selectedParser);
+ select.addEventListener('change', this._onParserChange.bind(this));
+ this._queryParserSelect.appendChild(select);
}
}
- renderParseResult() {
- const parseResult = this.getParseResult();
- this.queryParser.textContent = '';
+ _renderParseResult() {
+ const parseResult = this._getParseResult();
+ this._queryParser.textContent = '';
if (!parseResult) { return; }
- this.queryParser.appendChild(this.queryParserGenerator.createParseResult(parseResult.content));
+ this._queryParser.appendChild(this._queryParserGenerator.createParseResult(parseResult.content));
}
}
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index 871c576b..96e8a70b 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -17,11 +17,13 @@
/* global
* ClipboardMonitor
+ * DOM
* Display
* QueryParser
* apiClipboardGet
- * apiOptionsSet
+ * apiModifySettings
* apiTermsFind
+ * wanakana
*/
class DisplaySearch extends Display {
@@ -72,15 +74,11 @@ class DisplaySearch extends Display {
]);
}
- static create() {
- const instance = new DisplaySearch();
- instance.prepare();
- return instance;
- }
-
async prepare() {
try {
await super.prepare();
+ await this.updateOptions();
+ yomichan.on('optionsUpdated', () => this.updateOptions());
await this.queryParser.prepare();
const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
@@ -89,7 +87,7 @@ class DisplaySearch extends Display {
if (this.options.general.enableWanakana === true) {
this.wanakanaEnable.checked = true;
- window.wanakana.bind(this.query);
+ wanakana.bind(this.query);
} else {
this.wanakanaEnable.checked = false;
}
@@ -125,10 +123,10 @@ class DisplaySearch extends Display {
}
onError(error) {
- logError(error, true);
+ yomichan.logError(error);
}
- onSearchClear() {
+ onEscape() {
if (this.query === null) {
return;
}
@@ -181,7 +179,7 @@ class DisplaySearch extends Display {
}
onKeyDown(e) {
- const key = Display.getKeyFromEvent(e);
+ const key = DOM.getKeyFromEvent(e);
const ignoreKeys = this._onKeyDownIgnoreKeys;
const activeModifierMap = new Map([
@@ -236,7 +234,7 @@ class DisplaySearch extends Display {
this.setIntroVisible(!valid, animate);
this.updateSearchButton();
if (valid) {
- const {definitions} = await apiTermsFind(query, details, this.optionsContext);
+ const {definitions} = await apiTermsFind(query, details, this.getOptionsContext());
this.setContent('terms', {definitions, context: {
focus: false,
disableHistory: true,
@@ -254,13 +252,19 @@ class DisplaySearch extends Display {
}
onWanakanaEnableChange(e) {
- const enableWanakana = e.target.checked;
- if (enableWanakana) {
- window.wanakana.bind(this.query);
+ const value = e.target.checked;
+ if (value) {
+ wanakana.bind(this.query);
} else {
- window.wanakana.unbind(this.query);
+ wanakana.unbind(this.query);
}
- apiOptionsSet({general: {enableWanakana}}, this.getOptionsContext());
+ apiModifySettings([{
+ action: 'set',
+ path: 'general.enableWanakana',
+ value,
+ scope: 'profile',
+ optionsContext: this.getOptionsContext()
+ }], 'search');
}
onClipboardMonitorEnableChange(e) {
@@ -270,7 +274,13 @@ class DisplaySearch extends Display {
(granted) => {
if (granted) {
this.clipboardMonitor.start();
- apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext());
+ apiModifySettings([{
+ action: 'set',
+ path: 'general.enableClipboardMonitor',
+ value: true,
+ scope: 'profile',
+ optionsContext: this.getOptionsContext()
+ }], 'search');
} else {
e.target.checked = false;
}
@@ -278,7 +288,13 @@ class DisplaySearch extends Display {
);
} else {
this.clipboardMonitor.stop();
- apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext());
+ apiModifySettings([{
+ action: 'set',
+ path: 'general.enableClipboardMonitor',
+ value: false,
+ scope: 'profile',
+ optionsContext: this.getOptionsContext()
+ }], 'search');
}
}
@@ -298,11 +314,16 @@ class DisplaySearch extends Display {
}
setQuery(query) {
- const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query;
+ const interpretedQuery = this.isWanakanaEnabled() ? wanakana.toKana(query) : query;
this.query.value = interpretedQuery;
this.queryParser.setText(interpretedQuery);
}
+ async setContent(type, details) {
+ this.query.blur();
+ await super.setContent(type, details);
+ }
+
setIntroVisible(visible, animate) {
if (this.introVisible === visible) {
return;
@@ -376,5 +397,3 @@ class DisplaySearch extends Display {
}
}
}
-
-DisplaySearch.instance = DisplaySearch.create();
diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js
index b32a9517..ff1277ed 100644
--- a/ext/bg/js/settings/anki.js
+++ b/ext/bg/js/settings/anki.js
@@ -16,13 +16,13 @@
*/
/* global
+ * apiGetAnkiDeckNames
+ * apiGetAnkiModelFieldNames
+ * apiGetAnkiModelNames
* getOptionsContext
* getOptionsMutable
* onFormOptionsChanged
* settingsSaveOptions
- * utilAnkiGetDeckNames
- * utilAnkiGetModelFieldNames
- * utilAnkiGetModelNames
* utilBackgroundIsolate
*/
@@ -107,7 +107,7 @@ async function _ankiDeckAndModelPopulate(options) {
const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'};
try {
_ankiSpinnerShow(true);
- const [deckNames, modelNames] = await Promise.all([utilAnkiGetDeckNames(), utilAnkiGetModelNames()]);
+ const [deckNames, modelNames] = await Promise.all([apiGetAnkiDeckNames(), apiGetAnkiModelNames()]);
deckNames.sort();
modelNames.sort();
termsDeck.values = deckNames;
@@ -180,7 +180,7 @@ async function _onAnkiModelChanged(e) {
let fieldNames;
try {
const modelName = node.value;
- fieldNames = await utilAnkiGetModelFieldNames(modelName);
+ fieldNames = await apiGetAnkiModelFieldNames(modelName);
_ankiSetError(null);
} catch (error) {
_ankiSetError(error);
diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js
index 3c6e126c..ac2d82f3 100644
--- a/ext/bg/js/settings/audio.js
+++ b/ext/bg/js/settings/audio.js
@@ -18,7 +18,6 @@
/* global
* AudioSourceUI
* AudioSystem
- * apiAudioGetUri
* getOptionsContext
* getOptionsMutable
* settingsSaveOptions
@@ -29,10 +28,8 @@ let audioSystem = null;
async function audioSettingsInitialize() {
audioSystem = new AudioSystem({
- getAudioUri: async (definition, source) => {
- const optionsContext = getOptionsContext();
- return await apiAudioGetUri(definition, source, optionsContext);
- }
+ audioUriBuilder: null,
+ useCache: true
});
const optionsContext = getOptionsContext();
@@ -115,7 +112,7 @@ function textToSpeechTest() {
const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || '';
const voiceUri = document.querySelector('#text-to-speech-voice').value;
- const audio = audioSystem.createTextToSpeechAudio({text, voiceUri});
+ const audio = audioSystem.createTextToSpeechAudio(text, voiceUri);
audio.volume = 1.0;
audio.play();
} catch (e) {
diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js
index bdfef658..faf4e592 100644
--- a/ext/bg/js/settings/backup.js
+++ b/ext/bg/js/settings/backup.js
@@ -133,7 +133,7 @@ async function _settingsImportSetOptionsFull(optionsFull) {
}
function _showSettingsImportError(error) {
- logError(error);
+ yomichan.logError(error);
document.querySelector('#settings-import-error-modal-message').textContent = `${error}`;
$('#settings-import-error-modal').modal('show');
}
diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js
index 84498b42..031689a7 100644
--- a/ext/bg/js/settings/conditions-ui.js
+++ b/ext/bg/js/settings/conditions-ui.js
@@ -16,6 +16,7 @@
*/
/* global
+ * DOM
* conditionsNormalizeOptionValue
*/
@@ -103,11 +104,11 @@ ConditionsUI.Container = class Container {
if (hasOwn(conditionDescriptor.operators, operator)) {
const operatorDescriptor = conditionDescriptor.operators[operator];
if (hasOwn(operatorDescriptor, 'defaultValue')) {
- return {value: operatorDescriptor.defaultValue, fromOperator: true};
+ return {value: this.isolate(operatorDescriptor.defaultValue), fromOperator: true};
}
}
if (hasOwn(conditionDescriptor, 'defaultValue')) {
- return {value: conditionDescriptor.defaultValue, fromOperator: false};
+ return {value: this.isolate(conditionDescriptor.defaultValue), fromOperator: false};
}
}
return {fromOperator: false};
@@ -177,7 +178,8 @@ ConditionsUI.Condition = class Condition {
this.parent = parent;
this.condition = condition;
this.container = ConditionsUI.instantiateTemplate('#condition-template').appendTo(parent.container);
- this.input = this.container.find('input');
+ this.input = this.container.find('.condition-input');
+ this.inputInner = null;
this.typeSelect = this.container.find('.condition-type');
this.operatorSelect = this.container.find('.condition-operator');
this.removeButton = this.container.find('.condition-remove');
@@ -186,14 +188,13 @@ ConditionsUI.Condition = class Condition {
this.updateOperators();
this.updateInput();
- this.input.on('change', this.onInputChanged.bind(this));
this.typeSelect.on('change', this.onConditionTypeChanged.bind(this));
this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this));
this.removeButton.on('click', this.onRemoveClicked.bind(this));
}
cleanup() {
- this.input.off('change');
+ this.inputInner.off('change');
this.typeSelect.off('change');
this.operatorSelect.off('change');
this.removeButton.off('click');
@@ -204,6 +205,10 @@ ConditionsUI.Condition = class Condition {
this.parent.save();
}
+ isolate(object) {
+ return this.parent.isolate(object);
+ }
+
updateTypes() {
const conditionDescriptors = this.parent.parent.conditionDescriptors;
const optionGroup = this.typeSelect.find('optgroup');
@@ -236,21 +241,48 @@ ConditionsUI.Condition = class Condition {
updateInput() {
const conditionDescriptors = this.parent.parent.conditionDescriptors;
const {type, operator} = this.condition;
- const props = new Map([
- ['placeholder', ''],
- ['type', 'text']
- ]);
const objects = [];
+ let inputType = null;
if (hasOwn(conditionDescriptors, type)) {
const conditionDescriptor = conditionDescriptors[type];
objects.push(conditionDescriptor);
+ if (hasOwn(conditionDescriptor, 'type')) {
+ inputType = conditionDescriptor.type;
+ }
if (hasOwn(conditionDescriptor.operators, operator)) {
const operatorDescriptor = conditionDescriptor.operators[operator];
objects.push(operatorDescriptor);
+ if (hasOwn(operatorDescriptor, 'type')) {
+ inputType = operatorDescriptor.type;
+ }
}
}
+ this.input.empty();
+ if (inputType === 'select') {
+ this.inputInner = this.createSelectElement(objects);
+ } else if (inputType === 'keyMulti') {
+ this.inputInner = this.createInputKeyMultiElement(objects);
+ } else {
+ this.inputInner = this.createInputElement(objects);
+ }
+ this.inputInner.appendTo(this.input);
+ this.inputInner.on('change', this.onInputChanged.bind(this));
+
+ const {valid, value} = this.validateValue(this.condition.value);
+ this.inputInner.toggleClass('is-invalid', !valid);
+ this.inputInner.val(value);
+ }
+
+ createInputElement(objects) {
+ const inputInner = ConditionsUI.instantiateTemplate('#condition-input-text-template');
+
+ const props = new Map([
+ ['placeholder', ''],
+ ['type', 'text']
+ ]);
+
for (const object of objects) {
if (hasOwn(object, 'placeholder')) {
props.set('placeholder', object.placeholder);
@@ -266,35 +298,124 @@ ConditionsUI.Condition = class Condition {
}
for (const [prop, value] of props.entries()) {
- this.input.prop(prop, value);
+ inputInner.prop(prop, value);
}
- const {valid} = this.validateValue(this.condition.value);
- this.input.toggleClass('is-invalid', !valid);
- this.input.val(this.condition.value);
+ return inputInner;
}
- validateValue(value) {
+ createInputKeyMultiElement(objects) {
+ const inputInner = this.createInputElement(objects);
+
+ inputInner.prop('readonly', true);
+
+ let values = [];
+ let keySeparator = ' + ';
+ for (const object of objects) {
+ if (hasOwn(object, 'values')) {
+ values = object.values;
+ }
+ if (hasOwn(object, 'keySeparator')) {
+ keySeparator = object.keySeparator;
+ }
+ }
+
+ const pressedKeyIndices = new Set();
+
+ const onKeyDown = ({originalEvent}) => {
+ const pressedKeyEventName = DOM.getKeyFromEvent(originalEvent);
+ if (pressedKeyEventName === 'Escape' || pressedKeyEventName === 'Backspace') {
+ pressedKeyIndices.clear();
+ inputInner.val('');
+ inputInner.change();
+ return;
+ }
+
+ const pressedModifiers = DOM.getActiveModifiers(originalEvent);
+ // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
+ // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta
+ // It works with mouse events on some platforms, so try to determine if metaKey is pressed
+ // hack; only works when Shift and Alt are not pressed
+ const isMetaKeyChrome = (
+ pressedKeyEventName === 'Meta' &&
+ getSetDifference(new Set(['shift', 'alt']), pressedModifiers).size !== 0
+ );
+ if (isMetaKeyChrome) {
+ pressedModifiers.add('meta');
+ }
+
+ for (const modifier of pressedModifiers) {
+ const foundIndex = values.findIndex(({optionValue}) => optionValue === modifier);
+ if (foundIndex !== -1) {
+ pressedKeyIndices.add(foundIndex);
+ }
+ }
+
+ const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(keySeparator);
+ inputInner.val(inputValue);
+ inputInner.change();
+ };
+
+ inputInner.on('keydown', onKeyDown);
+
+ return inputInner;
+ }
+
+ createSelectElement(objects) {
+ const inputInner = ConditionsUI.instantiateTemplate('#condition-input-select-template');
+
+ const data = new Map([
+ ['values', []],
+ ['defaultValue', null]
+ ]);
+
+ for (const object of objects) {
+ if (hasOwn(object, 'values')) {
+ data.set('values', object.values);
+ }
+ if (hasOwn(object, 'defaultValue')) {
+ data.set('defaultValue', this.isolate(object.defaultValue));
+ }
+ }
+
+ for (const {optionValue, name} of data.get('values')) {
+ const option = ConditionsUI.instantiateTemplate('#condition-input-option-template');
+ option.attr('value', optionValue);
+ option.text(name);
+ option.appendTo(inputInner);
+ }
+
+ const defaultValue = data.get('defaultValue');
+ if (defaultValue !== null) {
+ inputInner.val(this.isolate(defaultValue));
+ }
+
+ return inputInner;
+ }
+
+ validateValue(value, isInput=false) {
const conditionDescriptors = this.parent.parent.conditionDescriptors;
let valid = true;
+ let inputTransformedValue = null;
try {
- value = conditionsNormalizeOptionValue(
+ [value, inputTransformedValue] = conditionsNormalizeOptionValue(
conditionDescriptors,
this.condition.type,
this.condition.operator,
- value
+ value,
+ isInput
);
} catch (e) {
valid = false;
}
- return {valid, value};
+ return {valid, value, inputTransformedValue};
}
onInputChanged() {
- const {valid, value} = this.validateValue(this.input.val());
- this.input.toggleClass('is-invalid', !valid);
- this.input.val(value);
- this.condition.value = value;
+ const {valid, value, inputTransformedValue} = this.validateValue(this.inputInner.val(), true);
+ this.inputInner.toggleClass('is-invalid', !valid);
+ this.inputInner.val(value);
+ this.condition.value = inputTransformedValue !== null ? inputTransformedValue : value;
this.save();
}
diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js
index 1a6d452b..632c01ea 100644
--- a/ext/bg/js/settings/dictionaries.js
+++ b/ext/bg/js/settings/dictionaries.js
@@ -17,8 +17,13 @@
/* global
* PageExitPrevention
+ * apiDeleteDictionary
+ * apiGetDictionaryCounts
+ * apiGetDictionaryInfo
+ * apiImportDictionaryArchive
* apiOptionsGet
* apiOptionsGetFull
+ * apiPurgeDatabase
* getOptionsContext
* getOptionsFullMutable
* getOptionsMutable
@@ -26,11 +31,6 @@
* storageEstimate
* storageUpdateStats
* utilBackgroundIsolate
- * utilDatabaseDeleteDictionary
- * utilDatabaseGetDictionaryCounts
- * utilDatabaseGetDictionaryInfo
- * utilDatabaseImport
- * utilDatabasePurge
*/
let dictionaryUI = null;
@@ -312,7 +312,7 @@ class SettingsDictionaryEntryUI {
progressBar.style.width = `${percent}%`;
};
- await utilDatabaseDeleteDictionary(this.dictionaryInfo.title, onProgress, {rate: 1000});
+ await apiDeleteDictionary(this.dictionaryInfo.title, onProgress);
} catch (e) {
dictionaryErrorsShow([e]);
} finally {
@@ -431,7 +431,7 @@ async function onDictionaryOptionsChanged() {
async function onDatabaseUpdated() {
try {
- const dictionaries = await utilDatabaseGetDictionaryInfo();
+ const dictionaries = await apiGetDictionaryInfo();
dictionaryUI.setDictionaries(dictionaries);
document.querySelector('#dict-warning').hidden = (dictionaries.length > 0);
@@ -439,7 +439,7 @@ async function onDatabaseUpdated() {
updateMainDictionarySelectOptions(dictionaries);
await updateMainDictionarySelectValue();
- const {counts, total} = await utilDatabaseGetDictionaryCounts(dictionaries.map((v) => v.title), true);
+ const {counts, total} = await apiGetDictionaryCounts(dictionaries.map((v) => v.title), true);
dictionaryUI.setCounts(counts, total);
} catch (e) {
dictionaryErrorsShow([e]);
@@ -554,7 +554,7 @@ function dictionaryErrorsShow(errors) {
if (errors !== null && errors.length > 0) {
const uniqueErrors = new Map();
for (let e of errors) {
- logError(e);
+ yomichan.logError(e);
e = dictionaryErrorToString(e);
let count = uniqueErrors.get(e);
if (typeof count === 'undefined') {
@@ -618,7 +618,7 @@ async function onDictionaryPurge(e) {
dictionaryErrorsShow(null);
dictionarySpinnerShow(true);
- await utilDatabasePurge();
+ await apiPurgeDatabase();
for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) {
options.dictionaries = utilBackgroundIsolate({});
options.general.mainDictionary = '';
@@ -679,7 +679,8 @@ async function onDictionaryImport(e) {
dictImportInfo.textContent = `(${i + 1} of ${ii})`;
}
- const {result, errors} = await utilDatabaseImport(files[i], updateProgress, importDetails);
+ const archiveContent = await dictReadFile(files[i]);
+ const {result, errors} = await apiImportDictionaryArchive(archiveContent, importDetails, updateProgress);
for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) {
const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions();
dictionaryOptions.enabled = true;
@@ -713,6 +714,15 @@ async function onDictionaryImport(e) {
}
}
+function dictReadFile(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsBinaryString(file);
+ });
+}
+
async function onDatabaseEnablePrefixWildcardSearchesChanged(e) {
const optionsFull = await getOptionsFullMutable();
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index 308e92eb..61395b1c 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -21,6 +21,8 @@
* ankiInitialize
* ankiTemplatesInitialize
* ankiTemplatesUpdateValue
+ * apiForwardLogsToBackend
+ * apiGetEnvironmentInfo
* apiOptionsSave
* appearanceInitialize
* audioSettingsInitialize
@@ -130,6 +132,7 @@ async function formRead(options) {
options.anki.tags = utilBackgroundIsolate($('#card-tags').val().split(/[,; ]+/));
options.anki.sentenceExt = parseInt($('#sentence-detection-extent').val(), 10);
options.anki.server = $('#interface-server').val();
+ options.anki.duplicateScope = $('#duplicate-scope').val();
options.anki.screenshot.format = $('#screenshot-format').val();
options.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10);
@@ -211,6 +214,7 @@ async function formWrite(options) {
$('#card-tags').val(options.anki.tags.join(' '));
$('#sentence-detection-extent').val(options.anki.sentenceExt);
$('#interface-server').val(options.anki.server);
+ $('#duplicate-scope').val(options.anki.duplicateScope);
$('#screenshot-format').val(options.anki.screenshot.format);
$('#screenshot-quality').val(options.anki.screenshot.quality);
@@ -282,12 +286,31 @@ function showExtensionInformation() {
node.textContent = `${manifest.name} v${manifest.version}`;
}
+async function settingsPopulateModifierKeys() {
+ const scanModifierKeySelect = document.querySelector('#scan-modifier-key');
+ scanModifierKeySelect.textContent = '';
+
+ const environment = await apiGetEnvironmentInfo();
+ const modifierKeys = [
+ {value: 'none', name: 'None'},
+ ...environment.modifiers.keys
+ ];
+ for (const {value, name} of modifierKeys) {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = name;
+ scanModifierKeySelect.appendChild(option);
+ }
+}
+
async function onReady() {
+ apiForwardLogsToBackend();
await yomichan.prepare();
showExtensionInformation();
+ await settingsPopulateModifierKeys();
formSetupEventListeners();
appearanceInitialize();
await audioSettingsInitialize();
diff --git a/ext/bg/js/settings/popup-preview-frame-main.js b/ext/bg/js/settings/popup-preview-frame-main.js
new file mode 100644
index 00000000..8228125f
--- /dev/null
+++ b/ext/bg/js/settings/popup-preview-frame-main.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2019-2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * SettingsPopupPreview
+ * apiForwardLogsToBackend
+ */
+
+(() => {
+ apiForwardLogsToBackend();
+ new SettingsPopupPreview();
+})();
diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js
index fba114e2..8901a0c4 100644
--- a/ext/bg/js/settings/popup-preview-frame.js
+++ b/ext/bg/js/settings/popup-preview-frame.js
@@ -18,8 +18,9 @@
/* global
* Frontend
* Popup
- * PopupProxyHost
+ * PopupFactory
* TextSourceRange
+ * apiFrameInformationGet
* apiOptionsGet
*/
@@ -32,46 +33,46 @@ class SettingsPopupPreview {
this.popupShown = false;
this.themeChangeTimeout = null;
this.textSource = null;
+ this.optionsContext = null;
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
this._windowMessageHandlers = new Map([
+ ['prepare', ({optionsContext}) => this.prepare(optionsContext)],
['setText', ({text}) => this.setText(text)],
['setCustomCss', ({css}) => this.setCustomCss(css)],
- ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)]
+ ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)],
+ ['updateOptionsContext', ({optionsContext}) => this.updateOptionsContext(optionsContext)]
]);
- }
- static create() {
- const instance = new SettingsPopupPreview();
- instance.prepare();
- return instance;
+ window.addEventListener('message', this.onMessage.bind(this), false);
}
- async prepare() {
- // Setup events
- window.addEventListener('message', this.onMessage.bind(this), false);
+ async prepare(optionsContext) {
+ this.optionsContext = optionsContext;
+ // Setup events
document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false);
// Overwrite API functions
window.apiOptionsGet = this.apiOptionsGet.bind(this);
// Overwrite frontend
- const popupHost = new PopupProxyHost();
- await popupHost.prepare();
+ const {frameId} = await apiFrameInformationGet();
+
+ const popupFactory = new PopupFactory(frameId);
+ await popupFactory.prepare();
- this.popup = popupHost.getOrCreatePopup();
+ this.popup = popupFactory.getOrCreatePopup();
this.popup.setChildrenSupported(false);
this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss;
this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this);
this.frontend = new Frontend(this.popup);
-
- this.frontend.setEnabled = () => {};
- this.frontend.searchClear = () => {};
-
+ this.frontend.getOptionsContext = async () => this.optionsContext;
await this.frontend.prepare();
+ this.frontend.setDisabledOverride(true);
+ this.frontend.canClearSelection = false;
// Update search
this.updateSearch();
@@ -122,7 +123,7 @@ class SettingsPopupPreview {
}
this.themeChangeTimeout = setTimeout(() => {
this.themeChangeTimeout = null;
- this.frontend.popup.updateTheme();
+ this.popup.updateTheme();
}, 300);
}
@@ -143,12 +144,18 @@ class SettingsPopupPreview {
setCustomCss(css) {
if (this.frontend === null) { return; }
- this.frontend.popup.setCustomCss(css);
+ this.popup.setCustomCss(css);
}
setCustomOuterCss(css) {
if (this.frontend === null) { return; }
- this.frontend.popup.setCustomOuterCss(css, false);
+ this.popup.setCustomOuterCss(css, false);
+ }
+
+ async updateOptionsContext(optionsContext) {
+ this.optionsContext = optionsContext;
+ await this.frontend.updateOptions();
+ await this.updateSearch();
}
async updateSearch() {
@@ -163,23 +170,17 @@ class SettingsPopupPreview {
const source = new TextSourceRange(range, range.toString(), null, null);
try {
- await this.frontend.onSearchSource(source, 'script');
- this.frontend.setCurrentTextSource(source);
+ await this.frontend.setTextSource(source);
} finally {
source.cleanup();
}
this.textSource = source;
await this.frontend.showContentCompleted();
- if (this.frontend.popup.isVisibleSync()) {
+ if (this.popup.isVisibleSync()) {
this.popupShown = true;
}
this.setInfoVisible(!this.popupShown);
}
}
-
-SettingsPopupPreview.instance = SettingsPopupPreview.create();
-
-
-
diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js
index 091872be..fdc3dd94 100644
--- a/ext/bg/js/settings/popup-preview.js
+++ b/ext/bg/js/settings/popup-preview.js
@@ -15,6 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+/* global
+ * getOptionsContext
+ * wanakana
+ */
function appearanceInitialize() {
let previewVisible = false;
@@ -37,7 +41,7 @@ function showAppearancePreview() {
frame.src = '/bg/settings-popup-preview.html';
frame.id = 'settings-popup-preview-frame';
- window.wanakana.bind(text[0]);
+ wanakana.bind(text[0]);
const targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
@@ -57,6 +61,23 @@ function showAppearancePreview() {
frame.contentWindow.postMessage({action, params}, targetOrigin);
});
+ const updateOptionsContext = () => {
+ const action = 'updateOptionsContext';
+ const params = {
+ optionsContext: getOptionsContext()
+ };
+ frame.contentWindow.postMessage({action, params}, targetOrigin);
+ };
+ yomichan.on('modifyingProfileChange', updateOptionsContext);
+
+ frame.addEventListener('load', () => {
+ const action = 'prepare';
+ const params = {
+ optionsContext: getOptionsContext()
+ };
+ frame.contentWindow.postMessage({action, params}, targetOrigin);
+ });
+
container.append(frame);
buttonContainer.remove();
settings.css('display', '');
diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js
index 867b17aa..bdf5a13d 100644
--- a/ext/bg/js/settings/profiles.js
+++ b/ext/bg/js/settings/profiles.js
@@ -23,6 +23,7 @@
* getOptionsFullMutable
* getOptionsMutable
* profileConditionsDescriptor
+ * profileConditionsDescriptorPromise
* settingsSaveOptions
* utilBackgroundIsolate
*/
@@ -98,6 +99,7 @@ async function profileFormWrite(optionsFull) {
profileConditionsContainer.cleanup();
}
+ await profileConditionsDescriptorPromise;
profileConditionsContainer = new ConditionsUI.Container(
profileConditionsDescriptor,
'popupLevel',
@@ -128,7 +130,7 @@ function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndi
}
async function profileOptionsUpdateTarget(optionsFull) {
- profileFormWrite(optionsFull);
+ await profileFormWrite(optionsFull);
const optionsContext = getOptionsContext();
const options = await getOptionsMutable(optionsContext);
@@ -190,6 +192,8 @@ async function onTargetProfileChanged() {
currentProfileIndex = index;
await profileOptionsUpdateTarget(optionsFull);
+
+ yomichan.trigger('modifyingProfileChange');
}
async function onProfileAdd() {
@@ -197,9 +201,13 @@ async function onProfileAdd() {
const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]);
profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100);
optionsFull.profiles.push(profile);
+
currentProfileIndex = optionsFull.profiles.length - 1;
+
await profileOptionsUpdateTarget(optionsFull);
await settingsSaveOptions();
+
+ yomichan.trigger('modifyingProfileChange');
}
async function onProfileRemove(e) {
@@ -238,6 +246,8 @@ async function onProfileRemoveConfirm() {
await profileOptionsUpdateTarget(optionsFull);
await settingsSaveOptions();
+
+ yomichan.trigger('modifyingProfileChange');
}
function onProfileNameChanged() {
@@ -263,6 +273,8 @@ async function onProfileMove(offset) {
await profileOptionsUpdateTarget(optionsFull);
await settingsSaveOptions();
+
+ yomichan.trigger('modifyingProfileChange');
}
async function onProfileCopy() {
diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js
index aaa1a0ec..3fd329d1 100644
--- a/ext/bg/js/translator.js
+++ b/ext/bg/js/translator.js
@@ -45,14 +45,8 @@ class Translator {
this.deinflector = new Deinflector(reasons);
}
- async purgeDatabase() {
+ clearDatabaseCaches() {
this.tagCache.clear();
- await this.database.purge();
- }
-
- async deleteDictionary(dictionaryName) {
- this.tagCache.clear();
- await this.database.deleteDictionary(dictionaryName);
}
async getSequencedDefinitions(definitions, mainDictionary) {
@@ -482,7 +476,9 @@ class Translator {
switch (mode) {
case 'freq':
for (const term of termsUnique[index]) {
- term.frequencies.push({expression, frequency: data, dictionary});
+ const frequencyData = this.getFrequencyData(expression, data, dictionary, term);
+ if (frequencyData === null) { continue; }
+ term.frequencies.push(frequencyData);
}
break;
case 'pitch':
@@ -575,6 +571,18 @@ class Translator {
return tagMetaList;
}
+ getFrequencyData(expression, data, dictionary, term) {
+ if (data !== null && typeof data === 'object') {
+ const {frequency, reading} = data;
+
+ const termReading = term.reading || expression;
+ if (reading !== termReading) { return null; }
+
+ return {expression, frequency, dictionary};
+ }
+ return {expression, frequency: data, dictionary};
+ }
+
async getPitchData(expression, data, dictionary, term) {
const reading = data.reading;
const termReading = term.reading || expression;
diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js
index 69536f02..8f86e47a 100644
--- a/ext/bg/js/util.js
+++ b/ext/bg/js/util.js
@@ -58,81 +58,14 @@ function utilBackgroundFunctionIsolate(func) {
return backgroundPage.utilFunctionIsolate(func);
}
-function utilStringHashCode(string) {
- let hashCode = 0;
-
- if (typeof string !== 'string') { return hashCode; }
-
- for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) {
- hashCode = ((hashCode << 5) - hashCode) + charCode;
- hashCode |= 0;
- }
-
- return hashCode;
-}
-
function utilBackend() {
const backend = chrome.extension.getBackgroundPage().yomichanBackend;
- if (!backend.isPrepared) {
+ if (!backend.isPrepared()) {
throw new Error('Backend not ready yet');
}
return backend;
}
-async function utilAnkiGetModelNames() {
- return utilIsolate(await utilBackend().anki.getModelNames());
-}
-
-async function utilAnkiGetDeckNames() {
- return utilIsolate(await utilBackend().anki.getDeckNames());
-}
-
-async function utilDatabaseGetDictionaryInfo() {
- return utilIsolate(await utilBackend().translator.database.getDictionaryInfo());
-}
-
-async function utilDatabaseGetDictionaryCounts(dictionaryNames, getTotal) {
- return utilIsolate(await utilBackend().translator.database.getDictionaryCounts(
- utilBackgroundIsolate(dictionaryNames),
- utilBackgroundIsolate(getTotal)
- ));
-}
-
-async function utilAnkiGetModelFieldNames(modelName) {
- return utilIsolate(await utilBackend().anki.getModelFieldNames(
- utilBackgroundIsolate(modelName)
- ));
-}
-
-async function utilDatabasePurge() {
- return utilIsolate(await utilBackend().translator.purgeDatabase());
-}
-
-async function utilDatabaseDeleteDictionary(dictionaryName, onProgress) {
- return utilIsolate(await utilBackend().translator.database.deleteDictionary(
- utilBackgroundIsolate(dictionaryName),
- utilBackgroundFunctionIsolate(onProgress)
- ));
-}
-
-async function utilDatabaseImport(data, onProgress, details) {
- data = await utilReadFile(data);
- return utilIsolate(await utilBackend().importDictionary(
- utilBackgroundIsolate(data),
- utilBackgroundFunctionIsolate(onProgress),
- utilBackgroundIsolate(details)
- ));
-}
-
-function utilReadFile(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result);
- reader.onerror = () => reject(reader.error);
- reader.readAsBinaryString(file);
- });
-}
-
function utilReadFileArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
diff --git a/ext/bg/legal.html b/ext/bg/legal.html
index 78acf79a..1ee9a28c 100644
--- a/ext/bg/legal.html
+++ b/ext/bg/legal.html
@@ -6,6 +6,7 @@
<title>Yomichan Legal</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
diff --git a/ext/bg/search.html b/ext/bg/search.html
index eacc1893..f3f156d8 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -6,6 +6,7 @@
<title>Yomichan Search</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -13,8 +14,6 @@
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/css/display.css">
- <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default">
- <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark">
</head>
<body>
<div class="container">
@@ -78,13 +77,14 @@
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
- <script src="/bg/js/japanese.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/source.js"></script>
<script src="/mixed/js/audio-system.js"></script>
<script src="/mixed/js/display-context.js"></script>
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/display-generator.js"></script>
+ <script src="/mixed/js/dynamic-loader.js"></script>
+ <script src="/mixed/js/media-loader.js"></script>
<script src="/mixed/js/scroll.js"></script>
<script src="/mixed/js/text-scanner.js"></script>
<script src="/mixed/js/template-handler.js"></script>
@@ -93,6 +93,7 @@
<script src="/bg/js/search-query-parser.js"></script>
<script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/search.js"></script>
- <script src="/bg/js/search-frontend.js"></script>
+
+ <script src="/bg/js/search-main.js"></script>
</body>
</html>
diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html
index f33ecedf..2f0b841b 100644
--- a/ext/bg/settings-popup-preview.html
+++ b/ext/bg/settings-popup-preview.html
@@ -6,6 +6,7 @@
<title>Yomichan Popup Preview</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -120,14 +121,17 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
<script src="/mixed/js/api.js"></script>
+ <script src="/mixed/js/dynamic-loader.js"></script>
<script src="/mixed/js/text-scanner.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/frontend-api-receiver.js"></script>
<script src="/fg/js/popup.js"></script>
<script src="/fg/js/source.js"></script>
- <script src="/fg/js/popup-proxy-host.js"></script>
+ <script src="/fg/js/popup-factory.js"></script>
<script src="/fg/js/frontend.js"></script>
<script src="/bg/js/settings/popup-preview-frame.js"></script>
+
+ <script src="/bg/js/settings/popup-preview-frame-main.js"></script>
</body>
</html>
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 96c1db82..3ce91f12 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -6,6 +6,7 @@
<title>Yomichan Options</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -116,7 +117,7 @@
<div class="input-group-btn"><select class="form-control btn btn-default condition-type"><optgroup label="Type"></optgroup></select></div>
<div class="input-group-btn"><select class="form-control btn btn-default condition-operator"><optgroup label="Operator"></optgroup></select></div>
<div class="condition-line-break"></div>
- <div class="condition-input"><input type="text" class="form-control" /></div>
+ <div class="condition-input"></div>
<div class="input-group-btn"><button class="btn btn-danger condition-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div>
</div></template>
<template id="condition-group-separator-template"><div class="input-group">
@@ -125,6 +126,9 @@
<template id="condition-group-options-template"><div class="condition-group-options">
<button class="btn btn-default condition-add"><span class="glyphicon glyphicon-plus"></span></button>
</div></template>
+ <template id="condition-input-text-template"><input type="text" class="form-control condition-input-inner" /></template>
+ <template id="condition-input-select-template"><select class="form-control condition-input-inner"></select></template>
+ <template id="condition-input-option-template"><option></option></template>
</div>
<div>
@@ -408,12 +412,7 @@
<div class="form-group">
<label for="scan-modifier-key">Scan modifier key</label>
- <select class="form-control" id="scan-modifier-key">
- <option value="none">None</option>
- <option value="alt">Alt</option>
- <option value="ctrl">Ctrl</option>
- <option value="shift">Shift</option>
- </select>
+ <select class="form-control" id="scan-modifier-key"></select>
</div>
</div>
@@ -820,6 +819,14 @@
</div>
<div class="form-group options-advanced">
+ <label for="duplicate-scope">Duplicate scope</label>
+ <select class="form-control" id="duplicate-scope">
+ <option value="collection">Collection</option>
+ <option value="deck">Deck</option>
+ </select>
+ </div>
+
+ <div class="form-group options-advanced">
<label for="screenshot-format">Screenshot format</label>
<select class="form-control" id="screenshot-format">
<option value="png">PNG</option>
@@ -838,6 +845,8 @@
As Anki requires the first field in the model to be unique, it is recommended
that you set it to <code>{expression}</code> for term flashcards and <code>{character}</code> for
Kanji flashcards. You can use multiple markers per field by typing them in directly.
+ See <a href="https://foosoft.net/projects/yomichan#flashcard-configuration" target="_blank" rel="noopener">Flashcard Configuration</a>
+ on the Yomichan homepage for descriptions of the available markers.
</p>
<ul class="nav nav-tabs">
@@ -1116,6 +1125,7 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/environment.js"></script>
<script src="/mixed/js/api.js"></script>
<script src="/mixed/js/japanese.js"></script>
@@ -1124,7 +1134,6 @@
<script src="/bg/js/conditions.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
- <script src="/bg/js/japanese.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/page-exit-prevention.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
diff --git a/ext/fg/float.html b/ext/fg/float.html
index 3ccf68eb..89952524 100644
--- a/ext/fg/float.html
+++ b/ext/fg/float.html
@@ -6,13 +6,12 @@
<title></title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
<link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" href="/mixed/css/display.css">
- <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default">
- <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark">
</head>
<body>
<div id="spinner" hidden><img src="/mixed/img/spinner.gif"></div>
@@ -51,11 +50,13 @@
<script src="/mixed/js/display-context.js"></script>
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/display-generator.js"></script>
+ <script src="/mixed/js/dynamic-loader.js"></script>
+ <script src="/mixed/js/media-loader.js"></script>
<script src="/mixed/js/scroll.js"></script>
<script src="/mixed/js/template-handler.js"></script>
<script src="/fg/js/float.js"></script>
- <script src="/fg/js/popup-nested.js"></script>
+ <script src="/fg/js/float-main.js"></script>
</body>
</html>
diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/content-script-main.js
index 2b942258..57386b85 100644
--- a/ext/fg/js/frontend-initialize.js
+++ b/ext/fg/js/content-script-main.js
@@ -16,15 +16,18 @@
*/
/* global
+ * DOM
* FrameOffsetForwarder
* Frontend
+ * PopupFactory
* PopupProxy
- * PopupProxyHost
* apiBroadcastTab
+ * apiForwardLogsToBackend
+ * apiFrameInformationGet
* apiOptionsGet
*/
-async function createIframePopupProxy(url, frameOffsetForwarder) {
+async function createIframePopupProxy(frameOffsetForwarder, setDisabled) {
const rootPopupInformationPromise = yomichan.getTemporaryListenerResult(
chrome.runtime.onMessage,
({action, params}, {resolve}) => {
@@ -34,33 +37,41 @@ async function createIframePopupProxy(url, frameOffsetForwarder) {
}
);
apiBroadcastTab('rootPopupRequestInformationBroadcast');
- const {popupId, frameId} = await rootPopupInformationPromise;
+ const {popupId, frameId: parentFrameId} = await rootPopupInformationPromise;
const getFrameOffset = frameOffsetForwarder.getOffset.bind(frameOffsetForwarder);
- const popup = new PopupProxy(popupId, 0, null, frameId, url, getFrameOffset);
+ const popup = new PopupProxy(popupId, 0, null, parentFrameId, getFrameOffset, setDisabled);
await popup.prepare();
return popup;
}
async function getOrCreatePopup(depth) {
- const popupHost = new PopupProxyHost();
- await popupHost.prepare();
+ const {frameId} = await apiFrameInformationGet();
+ if (typeof frameId !== 'number') {
+ const error = new Error('Failed to get frameId');
+ yomichan.logError(error);
+ throw error;
+ }
- const popup = popupHost.getOrCreatePopup(null, null, depth);
+ const popupFactory = new PopupFactory(frameId);
+ await popupFactory.prepare();
+
+ const popup = popupFactory.getOrCreatePopup(null, null, depth);
return popup;
}
-async function createPopupProxy(depth, id, parentFrameId, url) {
- const popup = new PopupProxy(null, depth + 1, id, parentFrameId, url);
+async function createPopupProxy(depth, id, parentFrameId) {
+ const popup = new PopupProxy(null, depth + 1, id, parentFrameId);
await popup.prepare();
return popup;
}
-async function main() {
+(async () => {
+ apiForwardLogsToBackend();
await yomichan.prepare();
const data = window.frontendInitializationData || {};
@@ -78,8 +89,29 @@ async function main() {
let frontendPreparePromise = null;
let frameOffsetForwarder = null;
+ let iframePopupsInRootFrameAvailable = true;
+
+ const disableIframePopupsInRootFrame = () => {
+ iframePopupsInRootFrameAvailable = false;
+ applyOptions();
+ };
+
+ let urlUpdatedAt = 0;
+ let popupProxyUrlCached = url;
+ const getPopupProxyUrl = async () => {
+ const now = Date.now();
+ if (popups.proxy !== null && now - urlUpdatedAt > 500) {
+ popupProxyUrlCached = await popups.proxy.getUrl();
+ urlUpdatedAt = now;
+ }
+ return popupProxyUrlCached;
+ };
+
const applyOptions = async () => {
- const optionsContext = {depth: isSearchPage ? 0 : depth, url};
+ const optionsContext = {
+ depth: isSearchPage ? 0 : depth,
+ url: proxy ? await getPopupProxyUrl() : window.location.href
+ };
const options = await apiOptionsGet(optionsContext);
if (!proxy && frameOffsetForwarder === null) {
@@ -88,11 +120,11 @@ async function main() {
}
let popup;
- if (isIframe && options.general.showIframePopupsInRootFrame) {
- popup = popups.iframe || await createIframePopupProxy(url, frameOffsetForwarder);
+ if (isIframe && options.general.showIframePopupsInRootFrame && DOM.getFullscreenElement() === null && iframePopupsInRootFrameAvailable) {
+ popup = popups.iframe || await createIframePopupProxy(frameOffsetForwarder, disableIframePopupsInRootFrame);
popups.iframe = popup;
} else if (proxy) {
- popup = popups.proxy || await createPopupProxy(depth, id, parentFrameId, url);
+ popup = popups.proxy || await createPopupProxy(depth, id, parentFrameId);
popups.proxy = popup;
} else {
popup = popups.normal || await getOrCreatePopup(depth);
@@ -100,7 +132,8 @@ async function main() {
}
if (frontend === null) {
- frontend = new Frontend(popup);
+ const getUrl = proxy ? getPopupProxyUrl : null;
+ frontend = new Frontend(popup, getUrl);
frontendPreparePromise = frontend.prepare();
await frontendPreparePromise;
} else {
@@ -117,8 +150,7 @@ async function main() {
};
yomichan.on('optionsUpdated', applyOptions);
+ window.addEventListener('fullscreenchange', applyOptions, false);
await applyOptions();
-}
-
-main();
+})();
diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js
index 3b4cc28f..d639bc86 100644
--- a/ext/fg/js/document.js
+++ b/ext/fg/js/document.js
@@ -28,6 +28,9 @@ function docSetImposterStyle(style, propertyName, value) {
}
function docImposterCreate(element, isTextarea) {
+ const body = document.body;
+ if (body === null) { return [null, null]; }
+
const elementStyle = window.getComputedStyle(element);
const elementRect = element.getBoundingClientRect();
const documentRect = document.documentElement.getBoundingClientRect();
@@ -78,7 +81,7 @@ function docImposterCreate(element, isTextarea) {
}
container.appendChild(imposter);
- document.body.appendChild(container);
+ body.appendChild(container);
// Adjust size
const imposterRect = imposter.getBoundingClientRect();
@@ -156,7 +159,7 @@ function docSentenceExtract(source, extent) {
const sourceLocal = source.clone();
const position = sourceLocal.setStartOffset(extent);
- sourceLocal.setEndOffset(position + extent);
+ sourceLocal.setEndOffset(extent * 2 - position, true);
const content = sourceLocal.text();
let quoteStack = [];
diff --git a/ext/fg/js/dom-text-scanner.js b/ext/fg/js/dom-text-scanner.js
new file mode 100644
index 00000000..8fa67ede
--- /dev/null
+++ b/ext/fg/js/dom-text-scanner.js
@@ -0,0 +1,551 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/**
+ * A class used to scan text in a document.
+ */
+class DOMTextScanner {
+ /**
+ * Creates a new instance of a DOMTextScanner.
+ * @param node The DOM Node to start at.
+ * @param offset The character offset in to start at when node is a text node.
+ * Use 0 for non-text nodes.
+ */
+ constructor(node, offset, forcePreserveWhitespace=false, generateLayoutContent=true) {
+ const ruby = DOMTextScanner.getParentRubyElement(node);
+ const resetOffset = (ruby !== null);
+ if (resetOffset) { node = ruby; }
+
+ this._node = node;
+ this._offset = offset;
+ this._content = '';
+ this._remainder = 0;
+ this._resetOffset = resetOffset;
+ this._newlines = 0;
+ this._lineHasWhitespace = false;
+ this._lineHasContent = false;
+ this._forcePreserveWhitespace = forcePreserveWhitespace;
+ this._generateLayoutContent = generateLayoutContent;
+ }
+
+ /**
+ * Gets the current node being scanned.
+ * @returns A DOM Node.
+ */
+ get node() {
+ return this._node;
+ }
+
+ /**
+ * Gets the current offset corresponding to the node being scanned.
+ * This value is only applicable for text nodes.
+ * @returns An integer.
+ */
+ get offset() {
+ return this._offset;
+ }
+
+ /**
+ * Gets the remaining number of characters that weren't scanned in the last seek() call.
+ * This value is usually 0 unless the end of the document was reached.
+ * @returns An integer.
+ */
+ get remainder() {
+ return this._remainder;
+ }
+
+ /**
+ * Gets the accumulated content string resulting from calls to seek().
+ * @returns A string.
+ */
+ get content() {
+ return this._content;
+ }
+
+ /**
+ * Seeks a given length in the document and accumulates the text content.
+ * @param length A positive or negative integer corresponding to how many characters
+ * should be added to content. Content is only added to the accumulation string,
+ * never removed, so mixing seek calls with differently signed length values
+ * may give unexpected results.
+ * @returns this
+ */
+ seek(length) {
+ const forward = (length >= 0);
+ this._remainder = (forward ? length : -length);
+ if (length === 0) { return this; }
+
+ const TEXT_NODE = Node.TEXT_NODE;
+ const ELEMENT_NODE = Node.ELEMENT_NODE;
+
+ const generateLayoutContent = this._generateLayoutContent;
+ let node = this._node;
+ let lastNode = node;
+ let resetOffset = this._resetOffset;
+ let newlines = 0;
+ while (node !== null) {
+ let enterable = false;
+ const nodeType = node.nodeType;
+
+ if (nodeType === TEXT_NODE) {
+ lastNode = node;
+ if (!(
+ forward ?
+ this._seekTextNodeForward(node, resetOffset) :
+ this._seekTextNodeBackward(node, resetOffset)
+ )) {
+ // Length reached
+ break;
+ }
+ } else if (nodeType === ELEMENT_NODE) {
+ lastNode = node;
+ this._offset = 0;
+ [enterable, newlines] = DOMTextScanner.getElementSeekInfo(node);
+ if (newlines > this._newlines && generateLayoutContent) {
+ this._newlines = newlines;
+ }
+ }
+
+ const exitedNodes = [];
+ node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes);
+
+ for (const exitedNode of exitedNodes) {
+ if (exitedNode.nodeType !== ELEMENT_NODE) { continue; }
+ newlines = DOMTextScanner.getElementSeekInfo(exitedNode)[1];
+ if (newlines > this._newlines && generateLayoutContent) {
+ this._newlines = newlines;
+ }
+ }
+
+ resetOffset = true;
+ }
+
+ this._node = lastNode;
+ this._resetOffset = resetOffset;
+
+ return this;
+ }
+
+ // Private
+
+ /**
+ * Seeks forward in a text node.
+ * @param textNode The text node to use.
+ * @param resetOffset Whether or not the text offset should be reset.
+ * @returns true if scanning should continue, or false if the scan length has been reached.
+ */
+ _seekTextNodeForward(textNode, resetOffset) {
+ const nodeValue = textNode.nodeValue;
+ const nodeValueLength = nodeValue.length;
+ const [preserveNewlines, preserveWhitespace] = (
+ this._forcePreserveWhitespace ?
+ [true, true] :
+ DOMTextScanner.getWhitespaceSettings(textNode)
+ );
+
+ let lineHasWhitespace = this._lineHasWhitespace;
+ let lineHasContent = this._lineHasContent;
+ let content = this._content;
+ let offset = resetOffset ? 0 : this._offset;
+ let remainder = this._remainder;
+ let newlines = this._newlines;
+
+ while (offset < nodeValueLength) {
+ const char = nodeValue[offset];
+ const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
+ ++offset;
+
+ if (charAttributes === 0) {
+ // Character should be ignored
+ continue;
+ } else if (charAttributes === 1) {
+ // Character is collapsable whitespace
+ lineHasWhitespace = true;
+ } else {
+ // Character should be added to the content
+ if (newlines > 0) {
+ if (content.length > 0) {
+ const useNewlineCount = Math.min(remainder, newlines);
+ content += '\n'.repeat(useNewlineCount);
+ remainder -= useNewlineCount;
+ newlines -= useNewlineCount;
+ } else {
+ newlines = 0;
+ }
+ lineHasContent = false;
+ lineHasWhitespace = false;
+ if (remainder <= 0) {
+ --offset; // Revert character offset
+ break;
+ }
+ }
+
+ lineHasContent = (charAttributes === 2); // 3 = character is a newline
+
+ if (lineHasWhitespace) {
+ if (lineHasContent) {
+ content += ' ';
+ lineHasWhitespace = false;
+ if (--remainder <= 0) {
+ --offset; // Revert character offset
+ break;
+ }
+ } else {
+ lineHasWhitespace = false;
+ }
+ }
+
+ content += char;
+
+ if (--remainder <= 0) { break; }
+ }
+ }
+
+ this._lineHasWhitespace = lineHasWhitespace;
+ this._lineHasContent = lineHasContent;
+ this._content = content;
+ this._offset = offset;
+ this._remainder = remainder;
+ this._newlines = newlines;
+
+ return (remainder > 0);
+ }
+
+ /**
+ * Seeks backward in a text node.
+ * This function is nearly the same as _seekTextNodeForward, with the following differences:
+ * - Iteration condition is reversed to check if offset is greater than 0.
+ * - offset is reset to nodeValueLength instead of 0.
+ * - offset is decremented instead of incremented.
+ * - offset is decremented before getting the character.
+ * - offset is reverted by incrementing instead of decrementing.
+ * - content string is prepended instead of appended.
+ * @param textNode The text node to use.
+ * @param resetOffset Whether or not the text offset should be reset.
+ * @returns true if scanning should continue, or false if the scan length has been reached.
+ */
+ _seekTextNodeBackward(textNode, resetOffset) {
+ const nodeValue = textNode.nodeValue;
+ const nodeValueLength = nodeValue.length;
+ const [preserveNewlines, preserveWhitespace] = (
+ this._forcePreserveWhitespace ?
+ [true, true] :
+ DOMTextScanner.getWhitespaceSettings(textNode)
+ );
+
+ let lineHasWhitespace = this._lineHasWhitespace;
+ let lineHasContent = this._lineHasContent;
+ let content = this._content;
+ let offset = resetOffset ? nodeValueLength : this._offset;
+ let remainder = this._remainder;
+ let newlines = this._newlines;
+
+ while (offset > 0) {
+ --offset;
+ const char = nodeValue[offset];
+ const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
+
+ if (charAttributes === 0) {
+ // Character should be ignored
+ continue;
+ } else if (charAttributes === 1) {
+ // Character is collapsable whitespace
+ lineHasWhitespace = true;
+ } else {
+ // Character should be added to the content
+ if (newlines > 0) {
+ if (content.length > 0) {
+ const useNewlineCount = Math.min(remainder, newlines);
+ content = '\n'.repeat(useNewlineCount) + content;
+ remainder -= useNewlineCount;
+ newlines -= useNewlineCount;
+ } else {
+ newlines = 0;
+ }
+ lineHasContent = false;
+ lineHasWhitespace = false;
+ if (remainder <= 0) {
+ ++offset; // Revert character offset
+ break;
+ }
+ }
+
+ lineHasContent = (charAttributes === 2); // 3 = character is a newline
+
+ if (lineHasWhitespace) {
+ if (lineHasContent) {
+ content = ' ' + content;
+ lineHasWhitespace = false;
+ if (--remainder <= 0) {
+ ++offset; // Revert character offset
+ break;
+ }
+ } else {
+ lineHasWhitespace = false;
+ }
+ }
+
+ content = char + content;
+
+ if (--remainder <= 0) { break; }
+ }
+ }
+
+ this._lineHasWhitespace = lineHasWhitespace;
+ this._lineHasContent = lineHasContent;
+ this._content = content;
+ this._offset = offset;
+ this._remainder = remainder;
+ this._newlines = newlines;
+
+ return (remainder > 0);
+ }
+
+ // Static helpers
+
+ /**
+ * Gets the next node in the document for a specified scanning direction.
+ * @param node The current DOM Node.
+ * @param forward Whether to scan forward in the document or backward.
+ * @param visitChildren Whether the children of the current node should be visited.
+ * @param exitedNodes An array which stores nodes which were exited.
+ * @returns The next node in the document, or null if there is no next node.
+ */
+ static getNextNode(node, forward, visitChildren, exitedNodes) {
+ let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null;
+ if (next === null) {
+ while (true) {
+ exitedNodes.push(node);
+
+ next = (forward ? node.nextSibling : node.previousSibling);
+ if (next !== null) { break; }
+
+ next = node.parentNode;
+ if (next === null) { break; }
+
+ node = next;
+ }
+ }
+ return next;
+ }
+
+ /**
+ * Gets the parent element of a given Node.
+ * @param node The node to check.
+ * @returns The parent element if one exists, otherwise null.
+ */
+ static getParentElement(node) {
+ while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
+ node = node.parentNode;
+ }
+ return node;
+ }
+
+ /**
+ * Gets the parent <ruby> element of a given node, if one exists. For efficiency purposes,
+ * this only checks the immediate parent elements and does not check all ancestors, so
+ * there are cases where the node may be in a ruby element but it is not returned.
+ * @param node The node to check.
+ * @returns A <ruby> node if the input node is contained in one, otherwise null.
+ */
+ static getParentRubyElement(node) {
+ node = DOMTextScanner.getParentElement(node);
+ if (node !== null && node.nodeName.toUpperCase() === 'RT') {
+ node = node.parentNode;
+ if (node !== null && node.nodeName.toUpperCase() === 'RUBY') {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @returns [enterable: boolean, newlines: integer]
+ * The enterable value indicates whether the content of this node should be entered.
+ * The newlines value corresponds to the number of newline characters that should be added.
+ * 1 newline corresponds to a simple new line in the layout.
+ * 2 newlines corresponds to a significant visual distinction since the previous content.
+ */
+ static getElementSeekInfo(element) {
+ let enterable = true;
+ switch (element.nodeName.toUpperCase()) {
+ case 'HEAD':
+ case 'RT':
+ case 'SCRIPT':
+ case 'STYLE':
+ return [false, 0];
+ case 'BR':
+ return [false, 1];
+ case 'TEXTAREA':
+ case 'INPUT':
+ case 'BUTTON':
+ enterable = false;
+ break;
+ }
+
+ const style = window.getComputedStyle(element);
+ const display = style.display;
+
+ const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style));
+ let newlines = 0;
+
+ if (!visible) {
+ enterable = false;
+ } else {
+ switch (style.position) {
+ case 'absolute':
+ case 'fixed':
+ case 'sticky':
+ newlines = 2;
+ break;
+ }
+ if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) {
+ newlines = 1;
+ }
+ }
+
+ return [enterable, newlines];
+ }
+
+ /**
+ * Gets information about how whitespace characters are treated.
+ * @param textNode The Text node to check.
+ * @returns [preserveNewlines: boolean, preserveWhitespace: boolean]
+ * The value of preserveNewlines indicates whether or not newline characters are treated as line breaks.
+ * The value of preserveWhitespace indicates whether or not sequences of whitespace characters are collapsed.
+ */
+ static getWhitespaceSettings(textNode) {
+ const element = DOMTextScanner.getParentElement(textNode);
+ if (element !== null) {
+ const style = window.getComputedStyle(element);
+ switch (style.whiteSpace) {
+ case 'pre':
+ case 'pre-wrap':
+ case 'break-spaces':
+ return [true, true];
+ case 'pre-line':
+ return [true, false];
+ }
+ }
+ return [false, false];
+ }
+
+ /**
+ * Gets attributes for the specified character.
+ * @param character A string containing a single character.
+ * @returns An integer representing the attributes of the character.
+ * 0: Character should be ignored.
+ * 1: Character is collapsable whitespace.
+ * 2: Character should be added to the content.
+ * 3: Character should be added to the content and is a newline.
+ */
+ static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) {
+ switch (character.charCodeAt(0)) {
+ case 0x09: // Tab ('\t')
+ case 0x0c: // Form feed ('\f')
+ case 0x0d: // Carriage return ('\r')
+ case 0x20: // Space (' ')
+ return preserveWhitespace ? 2 : 1;
+ case 0x0a: // Line feed ('\n')
+ return preserveNewlines ? 3 : 1;
+ case 0x200c: // Zero-width non-joiner ('\u200c')
+ return 0;
+ default: // Other
+ return 2;
+ }
+ }
+
+ /**
+ * Checks whether a given style is visible or not.
+ * This function does not check style.display === 'none'.
+ * @param style An object implementing the CSSStyleDeclaration interface.
+ * @returns true if the style should result in an element being visible, otherwise false.
+ */
+ static isStyleVisible(style) {
+ return !(
+ style.visibility === 'hidden' ||
+ parseFloat(style.opacity) <= 0 ||
+ parseFloat(style.fontSize) <= 0 ||
+ (
+ !DOMTextScanner.isStyleSelectable(style) &&
+ (
+ DOMTextScanner.isCSSColorTransparent(style.color) ||
+ DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor)
+ )
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given style is selectable or not.
+ * @param style An object implementing the CSSStyleDeclaration interface.
+ * @returns true if the style is selectable, otherwise false.
+ */
+ static isStyleSelectable(style) {
+ return !(
+ style.userSelect === 'none' ||
+ style.webkitUserSelect === 'none' ||
+ style.MozUserSelect === 'none' ||
+ style.msUserSelect === 'none'
+ );
+ }
+
+ /**
+ * Checks whether a CSS color is transparent or not.
+ * @param cssColor A CSS color string, expected to be encoded in rgb(a) form.
+ * @returns true if the color is transparent, otherwise false.
+ */
+ static isCSSColorTransparent(cssColor) {
+ return (
+ typeof cssColor === 'string' &&
+ cssColor.startsWith('rgba(') &&
+ /,\s*0.?0*\)$/.test(cssColor)
+ );
+ }
+
+ /**
+ * Checks whether a CSS display value will cause a layout change for text.
+ * @param cssDisplay A CSS string corresponding to the value of the display property.
+ * @returns true if the layout is changed by this value, otherwise false.
+ */
+ static doesCSSDisplayChangeLayout(cssDisplay) {
+ let pos = cssDisplay.indexOf(' ');
+ if (pos >= 0) {
+ // Truncate to <display-outside> part
+ cssDisplay = cssDisplay.substring(0, pos);
+ }
+
+ pos = cssDisplay.indexOf('-');
+ if (pos >= 0) {
+ // Truncate to first part of kebab-case value
+ cssDisplay = cssDisplay.substring(0, pos);
+ }
+
+ switch (cssDisplay) {
+ case 'block':
+ case 'flex':
+ case 'grid':
+ case 'list': // list-item
+ case 'table': // table, table-*
+ return true;
+ case 'ruby': // rubt-*
+ return (pos >= 0);
+ default:
+ return false;
+ }
+ }
+}
diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/float-main.js
index c140f9c8..20771910 100644
--- a/ext/fg/js/popup-nested.js
+++ b/ext/fg/js/float-main.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019-2020 Yomichan Authors
+ * Copyright (C) 2020 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,24 +16,21 @@
*/
/* global
+ * DisplayFloat
+ * apiForwardLogsToBackend
* apiOptionsGet
+ * dynamicLoader
*/
-function injectPopupNested() {
- const scriptSrcs = [
+async function injectPopupNested() {
+ await dynamicLoader.loadScripts([
'/mixed/js/text-scanner.js',
'/fg/js/frontend-api-sender.js',
'/fg/js/popup.js',
'/fg/js/popup-proxy.js',
'/fg/js/frontend.js',
- '/fg/js/frontend-initialize.js'
- ];
- for (const src of scriptSrcs) {
- const script = document.createElement('script');
- script.async = false;
- script.src = src;
- document.body.appendChild(script);
- }
+ '/fg/js/content-script-main.js'
+ ]);
}
async function popupNestedInitialize(id, depth, parentFrameId, url) {
@@ -42,26 +39,23 @@ async function popupNestedInitialize(id, depth, parentFrameId, url) {
const applyOptions = async () => {
const optionsContext = {depth, url};
const options = await apiOptionsGet(optionsContext);
- const popupNestingMaxDepth = options.scanning.popupNestingMaxDepth;
-
- const maxPopupDepthExceeded = !(
- typeof popupNestingMaxDepth === 'number' &&
- typeof depth === 'number' &&
- depth < popupNestingMaxDepth
- );
- if (maxPopupDepthExceeded || optionsApplied) {
- return;
- }
+ const maxPopupDepthExceeded = !(typeof depth === 'number' && depth < options.scanning.popupNestingMaxDepth);
+ if (maxPopupDepthExceeded || optionsApplied) { return; }
optionsApplied = true;
+ yomichan.off('optionsUpdated', applyOptions);
window.frontendInitializationData = {id, depth, parentFrameId, url, proxy: true};
- injectPopupNested();
-
- yomichan.off('optionsUpdated', applyOptions);
+ await injectPopupNested();
};
yomichan.on('optionsUpdated', applyOptions);
await applyOptions();
}
+
+(async () => {
+ apiForwardLogsToBackend();
+ const display = new DisplayFloat();
+ await display.prepare();
+})();
diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js
index 5c2c50c2..845bf7f6 100644
--- a/ext/fg/js/float.js
+++ b/ext/fg/js/float.js
@@ -18,7 +18,7 @@
/* global
* Display
* apiBroadcastTab
- * apiGetMessageToken
+ * apiSendMessageToFrame
* popupNestedInitialize
*/
@@ -27,17 +27,11 @@ class DisplayFloat extends Display {
super(document.querySelector('#spinner'), document.querySelector('#definitions'));
this.autoPlayAudioTimer = null;
- this._popupId = null;
-
- this.optionsContext = {
- depth: 0,
- url: window.location.href
- };
+ this._secret = yomichan.generateId(16);
+ this._token = null;
this._orphaned = false;
- this._prepareInvoked = false;
- this._messageToken = null;
- this._messageTokenPromise = null;
+ this._initializedNestedPopups = false;
this._onKeyDownHandlers = new Map([
['C', (e) => {
@@ -51,42 +45,30 @@ class DisplayFloat extends Display {
]);
this._windowMessageHandlers = new Map([
- ['setContent', ({type, details}) => this.setContent(type, details)],
- ['clearAutoPlayTimer', () => this.clearAutoPlayTimer()],
- ['setCustomCss', ({css}) => this.setCustomCss(css)],
- ['prepare', ({popupInfo, url, childrenSupported, scale}) => this.prepare(popupInfo, url, childrenSupported, scale)],
- ['setContentScale', ({scale}) => this.setContentScale(scale)]
+ ['initialize', {handler: this._initialize.bind(this), authenticate: false}],
+ ['configure', {handler: this._configure.bind(this)}],
+ ['setOptionsContext', {handler: ({optionsContext}) => this.setOptionsContext(optionsContext)}],
+ ['setContent', {handler: ({type, details}) => this.setContent(type, details)}],
+ ['clearAutoPlayTimer', {handler: () => this.clearAutoPlayTimer()}],
+ ['setCustomCss', {handler: ({css}) => this.setCustomCss(css)}],
+ ['setContentScale', {handler: ({scale}) => this.setContentScale(scale)}]
]);
-
- yomichan.on('orphaned', this.onOrphaned.bind(this));
- window.addEventListener('message', this.onMessage.bind(this), false);
}
- async prepare(popupInfo, url, childrenSupported, scale) {
- if (this._prepareInvoked) { return; }
- this._prepareInvoked = true;
-
- const {id, depth, parentFrameId} = popupInfo;
- this._popupId = id;
- this.optionsContext.depth = depth;
- this.optionsContext.url = url;
-
+ async prepare() {
await super.prepare();
- if (childrenSupported) {
- popupNestedInitialize(id, depth, parentFrameId, url);
- }
-
- this.setContentScale(scale);
+ yomichan.on('orphaned', this.onOrphaned.bind(this));
+ window.addEventListener('message', this.onMessage.bind(this), false);
- apiBroadcastTab('popupPrepareCompleted', {targetPopupId: this._popupId});
+ apiBroadcastTab('popupPrepared', {secret: this._secret});
}
onError(error) {
if (this._orphaned) {
this.setContent('orphaned');
} else {
- logError(error, true);
+ yomichan.logError(error);
}
}
@@ -94,7 +76,7 @@ class DisplayFloat extends Display {
this._orphaned = true;
}
- onSearchClear() {
+ onEscape() {
window.parent.postMessage('popupClose', '*');
}
@@ -104,46 +86,30 @@ class DisplayFloat extends Display {
onMessage(e) {
const data = e.data;
- if (typeof data !== 'object' || data === null) { return; } // Invalid data
-
- const token = data.token;
- if (typeof token !== 'string') { return; } // Invalid data
-
- if (this._messageToken === null) {
- // Async
- this.getMessageToken()
- .then(
- () => { this.handleAction(token, data); },
- () => {}
- );
- } else {
- // Sync
- this.handleAction(token, data);
+ if (typeof data !== 'object' || data === null) {
+ this._logMessageError(e, 'Invalid data');
+ return;
}
- }
- async getMessageToken() {
- // this._messageTokenPromise is used to ensure that only one call to apiGetMessageToken is made.
- if (this._messageTokenPromise === null) {
- this._messageTokenPromise = apiGetMessageToken();
- }
- const messageToken = await this._messageTokenPromise;
- if (this._messageToken === null) {
- this._messageToken = messageToken;
+ const action = data.action;
+ if (typeof action !== 'string') {
+ this._logMessageError(e, 'Invalid data');
+ return;
}
- this._messageTokenPromise = null;
- }
- handleAction(token, {action, params}) {
- if (token !== this._messageToken) {
- // Invalid token
+ const handlerInfo = this._windowMessageHandlers.get(action);
+ if (typeof handlerInfo === 'undefined') {
+ this._logMessageError(e, `Invalid action: ${JSON.stringify(action)}`);
return;
}
- const handler = this._windowMessageHandlers.get(action);
- if (typeof handler !== 'function') { return; }
+ if (handlerInfo.authenticate !== false && !this._isMessageAuthenticated(data)) {
+ this._logMessageError(e, 'Invalid authentication');
+ return;
+ }
- handler(params);
+ const handler = handlerInfo.handler;
+ handler(data.params);
}
autoPlayAudio() {
@@ -158,8 +124,15 @@ class DisplayFloat extends Display {
}
}
+ async setOptionsContext(optionsContext) {
+ this.optionsContext = optionsContext;
+ await this.updateOptions();
+ }
+
setContentScale(scale) {
- document.body.style.fontSize = `${scale}em`;
+ const body = document.body;
+ if (body === null) { return; }
+ body.style.fontSize = `${scale}em`;
}
async getDocumentTitle() {
@@ -188,6 +161,45 @@ class DisplayFloat extends Display {
return '';
}
}
-}
-DisplayFloat.instance = new DisplayFloat();
+ _logMessageError(event, type) {
+ yomichan.logWarning(new Error(`Popup received invalid message from origin ${JSON.stringify(event.origin)}: ${type}`));
+ }
+
+ _initialize(params) {
+ if (this._token !== null) { return; } // Already initialized
+ if (!isObject(params)) { return; } // Invalid data
+
+ const secret = params.secret;
+ if (secret !== this._secret) { return; } // Invalid authentication
+
+ const {token, frameId} = params;
+ this._token = token;
+
+ apiSendMessageToFrame(frameId, 'popupInitialized', {secret, token});
+ }
+
+ async _configure({messageId, frameId, popupId, optionsContext, childrenSupported, scale}) {
+ this.optionsContext = optionsContext;
+
+ await this.updateOptions();
+
+ if (childrenSupported && !this._initializedNestedPopups) {
+ const {depth, url} = optionsContext;
+ popupNestedInitialize(popupId, depth, frameId, url);
+ this._initializedNestedPopups = true;
+ }
+
+ this.setContentScale(scale);
+
+ apiSendMessageToFrame(frameId, 'popupConfigured', {messageId});
+ }
+
+ _isMessageAuthenticated(message) {
+ return (
+ this._token !== null &&
+ this._token === message.token &&
+ this._secret === message.secret
+ );
+ }
+}
diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js
index c658c55a..9b68d34e 100644
--- a/ext/fg/js/frame-offset-forwarder.js
+++ b/ext/fg/js/frame-offset-forwarder.js
@@ -23,6 +23,10 @@ class FrameOffsetForwarder {
constructor() {
this._started = false;
+ this._cacheMaxSize = 1000;
+ this._frameCache = new Set();
+ this._unreachableContentWindowCache = new Set();
+
this._forwardFrameOffset = (
window !== window.parent ?
this._forwardFrameOffsetParent.bind(this) :
@@ -74,12 +78,12 @@ class FrameOffsetForwarder {
_onGetFrameOffset(offset, uniqueId, e) {
let sourceFrame = null;
- for (const frame of document.querySelectorAll('frame, iframe:not(.yomichan-float)')) {
- if (frame.contentWindow !== e.source) { continue; }
- sourceFrame = frame;
- break;
+ if (!this._unreachableContentWindowCache.has(e.source)) {
+ sourceFrame = this._findFrameWithContentWindow(e.source);
}
if (sourceFrame === null) {
+ // closed shadow root etc.
+ this._addToCache(this._unreachableContentWindowCache, e.source);
this._forwardFrameOffsetOrigin(null, uniqueId);
return;
}
@@ -91,6 +95,67 @@ class FrameOffsetForwarder {
this._forwardFrameOffset(offset, uniqueId);
}
+ _findFrameWithContentWindow(contentWindow) {
+ const ELEMENT_NODE = Node.ELEMENT_NODE;
+ for (const elements of this._getFrameElementSources()) {
+ while (elements.length > 0) {
+ const element = elements.shift();
+ if (element.contentWindow === contentWindow) {
+ this._addToCache(this._frameCache, element);
+ return element;
+ }
+
+ const shadowRoot = (
+ element.shadowRoot ||
+ element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
+ );
+ if (shadowRoot) {
+ for (const child of shadowRoot.children) {
+ if (child.nodeType === ELEMENT_NODE) {
+ elements.push(child);
+ }
+ }
+ }
+
+ for (const child of element.children) {
+ if (child.nodeType === ELEMENT_NODE) {
+ elements.push(child);
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ *_getFrameElementSources() {
+ const frameCache = [];
+ for (const frame of this._frameCache) {
+ // removed from DOM
+ if (!frame.isConnected) {
+ this._frameCache.delete(frame);
+ continue;
+ }
+ frameCache.push(frame);
+ }
+ yield frameCache;
+ // will contain duplicates, but frame elements are cheap to handle
+ yield [...document.querySelectorAll('frame, iframe:not(.yomichan-float)')];
+ yield [document.documentElement];
+ }
+
+ _addToCache(cache, value) {
+ let freeSlots = this._cacheMaxSize - cache.size;
+ if (freeSlots <= 0) {
+ for (const cachedValue of cache) {
+ cache.delete(cachedValue);
+ ++freeSlots;
+ if (freeSlots > 0) { break; }
+ }
+ }
+ cache.add(value);
+ }
+
_forwardFrameOffsetParent(offset, uniqueId) {
window.parent.postMessage({action: 'getFrameOffset', params: {offset, uniqueId}}, '*');
}
diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js
index 4abd4e81..3fa9e8b6 100644
--- a/ext/fg/js/frontend-api-receiver.js
+++ b/ext/fg/js/frontend-api-receiver.js
@@ -17,41 +17,60 @@
class FrontendApiReceiver {
- constructor(source='', handlers=new Map()) {
+ constructor(source, messageHandlers) {
this._source = source;
- this._handlers = handlers;
+ this._messageHandlers = messageHandlers;
+ }
- chrome.runtime.onConnect.addListener(this.onConnect.bind(this));
+ prepare() {
+ chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
}
- onConnect(port) {
+ _onConnect(port) {
if (port.name !== 'frontend-api-receiver') { return; }
- port.onMessage.addListener(this.onMessage.bind(this, port));
+ port.onMessage.addListener(this._onMessage.bind(this, port));
}
- onMessage(port, {id, action, params, target, senderId}) {
+ _onMessage(port, {id, action, params, target, senderId}) {
if (target !== this._source) { return; }
- const handler = this._handlers.get(action);
- if (typeof handler !== 'function') { return; }
+ const messageHandler = this._messageHandlers.get(action);
+ if (typeof messageHandler === 'undefined') { return; }
+
+ const {handler, async} = messageHandler;
- this.sendAck(port, id, senderId);
+ this._sendAck(port, id, senderId);
+ if (async) {
+ this._invokeHandlerAsync(handler, params, port, id, senderId);
+ } else {
+ this._invokeHandler(handler, params, port, id, senderId);
+ }
+ }
+
+ _invokeHandler(handler, params, port, id, senderId) {
+ try {
+ const result = handler(params);
+ this._sendResult(port, id, senderId, {result});
+ } catch (error) {
+ this._sendResult(port, id, senderId, {error: errorToJson(error)});
+ }
+ }
- handler(params).then(
- (result) => {
- this.sendResult(port, id, senderId, {result});
- },
- (error) => {
- this.sendResult(port, id, senderId, {error: errorToJson(error)});
- });
+ async _invokeHandlerAsync(handler, params, port, id, senderId) {
+ try {
+ const result = await handler(params);
+ this._sendResult(port, id, senderId, {result});
+ } catch (error) {
+ this._sendResult(port, id, senderId, {error: errorToJson(error)});
+ }
}
- sendAck(port, id, senderId) {
+ _sendAck(port, id, senderId) {
port.postMessage({type: 'ack', id, senderId});
}
- sendResult(port, id, senderId, data) {
+ _sendResult(port, id, senderId, data) {
port.postMessage({type: 'result', id, senderId, data});
}
}
diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js
index 1d539cab..4dcde638 100644
--- a/ext/fg/js/frontend-api-sender.js
+++ b/ext/fg/js/frontend-api-sender.js
@@ -17,97 +17,97 @@
class FrontendApiSender {
- constructor() {
- this.senderId = yomichan.generateId(16);
- this.ackTimeout = 3000; // 3 seconds
- this.responseTimeout = 10000; // 10 seconds
- this.callbacks = new Map();
- this.disconnected = false;
- this.nextId = 0;
-
- this.port = null;
+ constructor(target) {
+ this._target = target;
+ this._senderId = yomichan.generateId(16);
+ this._ackTimeout = 3000; // 3 seconds
+ this._responseTimeout = 10000; // 10 seconds
+ this._callbacks = new Map();
+ this._disconnected = false;
+ this._nextId = 0;
+ this._port = null;
}
- invoke(action, params, target) {
- if (this.disconnected) {
+ invoke(action, params) {
+ if (this._disconnected) {
// attempt to reconnect the next time
- this.disconnected = false;
+ this._disconnected = false;
return Promise.reject(new Error('Disconnected'));
}
- if (this.port === null) {
- this.createPort();
+ if (this._port === null) {
+ this._createPort();
}
- const id = `${this.nextId}`;
- ++this.nextId;
+ const id = `${this._nextId}`;
+ ++this._nextId;
return new Promise((resolve, reject) => {
const info = {id, resolve, reject, ack: false, timer: null};
- this.callbacks.set(id, info);
- info.timer = setTimeout(() => this.onError(id, 'Timeout (ack)'), this.ackTimeout);
+ this._callbacks.set(id, info);
+ info.timer = setTimeout(() => this._onError(id, 'Timeout (ack)'), this._ackTimeout);
- this.port.postMessage({id, action, params, target, senderId: this.senderId});
+ this._port.postMessage({id, action, params, target: this._target, senderId: this._senderId});
});
}
- createPort() {
- this.port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'});
- this.port.onDisconnect.addListener(this.onDisconnect.bind(this));
- this.port.onMessage.addListener(this.onMessage.bind(this));
+ _createPort() {
+ this._port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'});
+ this._port.onDisconnect.addListener(this._onDisconnect.bind(this));
+ this._port.onMessage.addListener(this._onMessage.bind(this));
}
- onMessage({type, id, data, senderId}) {
- if (senderId !== this.senderId) { return; }
+ _onMessage({type, id, data, senderId}) {
+ if (senderId !== this._senderId) { return; }
switch (type) {
case 'ack':
- this.onAck(id);
+ this._onAck(id);
break;
case 'result':
- this.onResult(id, data);
+ this._onResult(id, data);
break;
}
}
- onDisconnect() {
- this.disconnected = true;
- this.port = null;
+ _onDisconnect() {
+ this._disconnected = true;
+ this._port = null;
- for (const id of this.callbacks.keys()) {
- this.onError(id, 'Disconnected');
+ for (const id of this._callbacks.keys()) {
+ this._onError(id, 'Disconnected');
}
}
- onAck(id) {
- const info = this.callbacks.get(id);
+ _onAck(id) {
+ const info = this._callbacks.get(id);
if (typeof info === 'undefined') {
- console.warn(`ID ${id} not found for ack`);
+ yomichan.logWarning(new Error(`ID ${id} not found for ack`));
return;
}
if (info.ack) {
- console.warn(`Request ${id} already ack'd`);
+ yomichan.logWarning(new Error(`Request ${id} already ack'd`));
return;
}
info.ack = true;
clearTimeout(info.timer);
- info.timer = setTimeout(() => this.onError(id, 'Timeout (response)'), this.responseTimeout);
+ info.timer = setTimeout(() => this._onError(id, 'Timeout (response)'), this._responseTimeout);
}
- onResult(id, data) {
- const info = this.callbacks.get(id);
+ _onResult(id, data) {
+ const info = this._callbacks.get(id);
if (typeof info === 'undefined') {
- console.warn(`ID ${id} not found`);
+ yomichan.logWarning(new Error(`ID ${id} not found`));
return;
}
if (!info.ack) {
- console.warn(`Request ${id} not ack'd`);
+ yomichan.logWarning(new Error(`Request ${id} not ack'd`));
return;
}
- this.callbacks.delete(id);
+ this._callbacks.delete(id);
clearTimeout(info.timer);
info.timer = null;
@@ -118,10 +118,10 @@ class FrontendApiSender {
}
}
- onError(id, reason) {
- const info = this.callbacks.get(id);
+ _onError(id, reason) {
+ const info = this._callbacks.get(id);
if (typeof info === 'undefined') { return; }
- this.callbacks.delete(id);
+ this._callbacks.delete(id);
info.timer = null;
info.reject(new Error(reason));
}
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
index eecfe2e1..575dc413 100644
--- a/ext/fg/js/frontend.js
+++ b/ext/fg/js/frontend.js
@@ -25,73 +25,155 @@
* docSentenceExtract
*/
-class Frontend extends TextScanner {
- constructor(popup) {
- super(
- window,
- () => this.popup.isProxy() ? [] : [this.popup.getContainer()],
- [(x, y) => this.popup.containsPoint(x, y)]
- );
-
- this.popup = popup;
-
+class Frontend {
+ constructor(popup, getUrl=null) {
+ this._id = yomichan.generateId(16);
+ this._popup = popup;
+ this._getUrl = getUrl;
this._disabledOverride = false;
-
- this.options = null;
-
- this.optionsContext = {
- depth: popup.depth,
- url: popup.url
- };
-
+ this._options = null;
this._pageZoomFactor = 1.0;
this._contentScale = 1.0;
this._orphaned = false;
this._lastShowPromise = Promise.resolve();
+ this._enabledEventListeners = new EventListenerCollection();
+ this._activeModifiers = new Set();
+ this._optionsUpdatePending = false;
+ this._textScanner = new TextScanner({
+ node: window,
+ ignoreElements: () => this._popup.isProxy() ? [] : [this._popup.getFrame()],
+ ignorePoint: (x, y) => this._popup.containsPoint(x, y),
+ search: this._search.bind(this)
+ });
this._windowMessageHandlers = new Map([
- ['popupClose', () => this.onSearchClear(true)],
- ['selectionCopy', () => document.execCommand('copy')]
+ ['popupClose', this._onMessagePopupClose.bind(this)],
+ ['selectionCopy', this._onMessageSelectionCopy.bind()]
]);
this._runtimeMessageHandlers = new Map([
- ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }],
- ['rootPopupRequestInformationBroadcast', () => { this._broadcastRootPopupInformation(); }],
- ['requestDocumentInformationBroadcast', ({uniqueId}) => { this._broadcastDocumentInformation(uniqueId); }]
+ ['popupSetVisibleOverride', this._onMessagePopupSetVisibleOverride.bind(this)],
+ ['rootPopupRequestInformationBroadcast', this._onMessageRootPopupRequestInformationBroadcast.bind(this)],
+ ['requestDocumentInformationBroadcast', this._onMessageRequestDocumentInformationBroadcast.bind(this)]
]);
}
+ get canClearSelection() {
+ return this._textScanner.canClearSelection;
+ }
+
+ set canClearSelection(value) {
+ this._textScanner.canClearSelection = value;
+ }
+
async prepare() {
try {
await this.updateOptions();
const {zoomFactor} = await apiGetZoom();
this._pageZoomFactor = zoomFactor;
- window.addEventListener('resize', this.onResize.bind(this), false);
+ window.addEventListener('resize', this._onResize.bind(this), false);
const visualViewport = window.visualViewport;
if (visualViewport !== null && typeof visualViewport === 'object') {
- window.visualViewport.addEventListener('scroll', this.onVisualViewportScroll.bind(this));
- window.visualViewport.addEventListener('resize', this.onVisualViewportResize.bind(this));
+ window.visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this));
+ window.visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this));
}
- yomichan.on('orphaned', this.onOrphaned.bind(this));
+ yomichan.on('orphaned', this._onOrphaned.bind(this));
yomichan.on('optionsUpdated', this.updateOptions.bind(this));
- yomichan.on('zoomChanged', this.onZoomChanged.bind(this));
- chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
+ yomichan.on('zoomChanged', this._onZoomChanged.bind(this));
+ chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this));
+
+ this._textScanner.on('clearSelection', this._onClearSelection.bind(this));
+ this._textScanner.on('activeModifiersChanged', this._onActiveModifiersChanged.bind(this));
this._updateContentScale();
this._broadcastRootPopupInformation();
} catch (e) {
- this.onError(e);
+ yomichan.logError(e);
+ }
+ }
+
+ async setPopup(popup) {
+ this._textScanner.clearSelection(true);
+ this._popup = popup;
+ await popup.setOptionsContext(await this.getOptionsContext(), this._id);
+ }
+
+ setDisabledOverride(disabled) {
+ this._disabledOverride = disabled;
+ this._updateTextScannerEnabled();
+ }
+
+ async setTextSource(textSource) {
+ await this._search(textSource, 'script');
+ this._textScanner.setCurrentTextSource(textSource);
+ }
+
+ async getOptionsContext() {
+ const url = this._getUrl !== null ? await this._getUrl() : window.location.href;
+ const depth = this._popup.depth;
+ const modifierKeys = [...this._activeModifiers];
+ return {depth, url, modifierKeys};
+ }
+
+ async updateOptions() {
+ const optionsContext = await this.getOptionsContext();
+ this._options = await apiOptionsGet(optionsContext);
+ this._textScanner.setOptions(this._options);
+ this._updateTextScannerEnabled();
+
+ const ignoreNodes = ['.scan-disable', '.scan-disable *'];
+ if (!this._options.scanning.enableOnPopupExpressions) {
+ ignoreNodes.push('.source-text', '.source-text *');
+ }
+ this._textScanner.ignoreNodes = ignoreNodes.join(',');
+
+ await this._popup.setOptionsContext(optionsContext, this._id);
+
+ this._updateContentScale();
+
+ const textSourceCurrent = this._textScanner.getCurrentTextSource();
+ const causeCurrent = this._textScanner.causeCurrent;
+ if (textSourceCurrent !== null && causeCurrent !== null) {
+ await this._search(textSourceCurrent, causeCurrent);
}
}
- onResize() {
+ showContentCompleted() {
+ return this._lastShowPromise;
+ }
+
+ // Message handlers
+
+ _onMessagePopupClose() {
+ this._textScanner.clearSelection(false);
+ }
+
+ _onMessageSelectionCopy() {
+ document.execCommand('copy');
+ }
+
+ _onMessagePopupSetVisibleOverride({visible}) {
+ this._popup.setVisibleOverride(visible);
+ }
+
+ _onMessageRootPopupRequestInformationBroadcast() {
+ this._broadcastRootPopupInformation();
+ }
+
+ _onMessageRequestDocumentInformationBroadcast({uniqueId}) {
+ this._broadcastDocumentInformation(uniqueId);
+ }
+
+ // Private
+
+ _onResize() {
this._updatePopupPosition();
}
- onWindowMessage(e) {
+ _onWindowMessage(e) {
const action = e.data;
const handler = this._windowMessageHandlers.get(action);
if (typeof handler !== 'function') { return false; }
@@ -99,10 +181,7 @@ class Frontend extends TextScanner {
handler();
}
- onRuntimeMessage({action, params}, sender, callback) {
- const {targetPopupId} = params || {};
- if (typeof targetPopupId !== 'undefined' && targetPopupId !== this.popup.id) { return; }
-
+ _onRuntimeMessage({action, params}, sender, callback) {
const handler = this._runtimeMessageHandlers.get(action);
if (typeof handler !== 'function') { return false; }
@@ -111,112 +190,78 @@ class Frontend extends TextScanner {
return false;
}
- onOrphaned() {
+ _onOrphaned() {
this._orphaned = true;
}
- onZoomChanged({newZoomFactor}) {
+ _onZoomChanged({newZoomFactor}) {
this._pageZoomFactor = newZoomFactor;
this._updateContentScale();
}
- onVisualViewportScroll() {
+ _onVisualViewportScroll() {
this._updatePopupPosition();
}
- onVisualViewportResize() {
+ _onVisualViewportResize() {
this._updateContentScale();
}
- getMouseEventListeners() {
- return [
- ...super.getMouseEventListeners(),
- [window, 'message', this.onWindowMessage.bind(this)]
- ];
- }
-
- setDisabledOverride(disabled) {
- this._disabledOverride = disabled;
- this.setEnabled(this.options.general.enable, this._canEnable());
+ _onClearSelection({passive}) {
+ this._popup.hide(!passive);
+ this._popup.clearAutoPlayTimer();
+ this._updatePendingOptions();
}
- async setPopup(popup) {
- this.onSearchClear(false);
- this.popup = popup;
- await popup.setOptions(this.options);
- }
-
- async updateOptions() {
- this.options = await apiOptionsGet(this.getOptionsContext());
- this.setOptions(this.options, this._canEnable());
-
- const ignoreNodes = ['.scan-disable', '.scan-disable *'];
- if (!this.options.scanning.enableOnPopupExpressions) {
- ignoreNodes.push('.source-text', '.source-text *');
- }
- this.ignoreNodes = ignoreNodes.join(',');
-
- await this.popup.setOptions(this.options);
-
- this._updateContentScale();
-
- if (this.textSourceCurrent !== null && this.causeCurrent !== null) {
- await this.onSearchSource(this.textSourceCurrent, this.causeCurrent);
+ async _onActiveModifiersChanged({modifiers}) {
+ if (areSetsEqual(modifiers, this._activeModifiers)) { return; }
+ this._activeModifiers = modifiers;
+ if (await this._popup.isVisible()) {
+ this._optionsUpdatePending = true;
+ return;
}
+ await this.updateOptions();
}
- async onSearchSource(textSource, cause) {
+ async _search(textSource, cause) {
+ await this._updatePendingOptions();
+
let results = null;
try {
if (textSource !== null) {
+ const optionsContext = await this.getOptionsContext();
results = (
- await this.findTerms(textSource) ||
- await this.findKanji(textSource)
+ await this._findTerms(textSource, optionsContext) ||
+ await this._findKanji(textSource, optionsContext)
);
if (results !== null) {
const focus = (cause === 'mouse');
- this.showContent(textSource, focus, results.definitions, results.type);
+ this._showContent(textSource, focus, results.definitions, results.type, optionsContext);
}
}
} catch (e) {
if (this._orphaned) {
- if (textSource !== null && this.options.scanning.modifier !== 'none') {
- this._showPopupContent(textSource, 'orphaned');
+ if (textSource !== null && this._options.scanning.modifier !== 'none') {
+ this._showPopupContent(textSource, await this.getOptionsContext(), 'orphaned');
}
} else {
- this.onError(e);
+ yomichan.logError(e);
}
} finally {
- if (results === null && this.options.scanning.autoHideResults) {
- this.onSearchClear(true);
+ if (results === null && this._options.scanning.autoHideResults) {
+ this._textScanner.clearSelection(false);
}
}
return results;
}
- showContent(textSource, focus, definitions, type) {
- const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
- const url = window.location.href;
- this._showPopupContent(
- textSource,
- type,
- {definitions, context: {sentence, url, focus, disableHistory: true}}
- );
- }
-
- showContentCompleted() {
- return this._lastShowPromise;
- }
-
- async findTerms(textSource) {
- this.setTextSourceScanLength(textSource, this.options.scanning.length);
-
- const searchText = textSource.text();
+ async _findTerms(textSource, optionsContext) {
+ const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length);
if (searchText.length === 0) { return null; }
- const {definitions, length} = await apiTermsFind(searchText, {}, this.getOptionsContext());
+ const {definitions, length} = await apiTermsFind(searchText, {}, optionsContext);
if (definitions.length === 0) { return null; }
textSource.setEndOffset(length);
@@ -224,82 +269,97 @@ class Frontend extends TextScanner {
return {definitions, type: 'terms'};
}
- async findKanji(textSource) {
- this.setTextSourceScanLength(textSource, 1);
-
- const searchText = textSource.text();
+ async _findKanji(textSource, optionsContext) {
+ const searchText = this._textScanner.getTextSourceContent(textSource, 1);
if (searchText.length === 0) { return null; }
- const definitions = await apiKanjiFind(searchText, this.getOptionsContext());
+ const definitions = await apiKanjiFind(searchText, optionsContext);
if (definitions.length === 0) { return null; }
- return {definitions, type: 'kanji'};
- }
+ textSource.setEndOffset(1);
- onSearchClear(changeFocus) {
- this.popup.hide(changeFocus);
- this.popup.clearAutoPlayTimer();
- super.onSearchClear(changeFocus);
+ return {definitions, type: 'kanji'};
}
- getOptionsContext() {
- this.optionsContext.url = this.popup.url;
- return this.optionsContext;
+ _showContent(textSource, focus, definitions, type, optionsContext) {
+ const {url} = optionsContext;
+ const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt);
+ this._showPopupContent(
+ textSource,
+ optionsContext,
+ type,
+ {definitions, context: {sentence, url, focus, disableHistory: true}}
+ );
}
- _showPopupContent(textSource, type=null, details=null) {
- this._lastShowPromise = this.popup.showContent(
+ _showPopupContent(textSource, optionsContext, type=null, details=null) {
+ const context = {optionsContext, source: this._id};
+ this._lastShowPromise = this._popup.showContent(
textSource.getRect(),
textSource.getWritingMode(),
type,
- details
+ details,
+ context
);
return this._lastShowPromise;
}
+ async _updatePendingOptions() {
+ if (this._optionsUpdatePending) {
+ this._optionsUpdatePending = false;
+ await this.updateOptions();
+ }
+ }
+
+ _updateTextScannerEnabled() {
+ const enabled = (
+ this._options.general.enable &&
+ this._popup.depth <= this._options.scanning.popupNestingMaxDepth &&
+ !this._disabledOverride
+ );
+ this._enabledEventListeners.removeAllEventListeners();
+ this._textScanner.setEnabled(enabled);
+ if (enabled) {
+ this._enabledEventListeners.addEventListener(window, 'message', this._onWindowMessage.bind(this));
+ }
+ }
+
_updateContentScale() {
- const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this.options.general;
+ const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general;
let contentScale = popupScalingFactor;
if (popupScaleRelativeToPageZoom) {
contentScale /= this._pageZoomFactor;
}
if (popupScaleRelativeToVisualViewport) {
- contentScale /= Frontend._getVisualViewportScale();
+ const visualViewport = window.visualViewport;
+ const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0);
+ contentScale /= visualViewportScale;
}
if (contentScale === this._contentScale) { return; }
this._contentScale = contentScale;
- this.popup.setContentScale(this._contentScale);
+ this._popup.setContentScale(this._contentScale);
this._updatePopupPosition();
}
+ async _updatePopupPosition() {
+ const textSource = this._textScanner.getCurrentTextSource();
+ if (textSource !== null && await this._popup.isVisible()) {
+ this._showPopupContent(textSource, await this.getOptionsContext());
+ }
+ }
+
_broadcastRootPopupInformation() {
- if (!this.popup.isProxy() && this.popup.depth === 0 && this.popup.frameId === 0) {
- apiBroadcastTab('rootPopupInformation', {popupId: this.popup.id, frameId: this.popup.frameId});
+ if (!this._popup.isProxy() && this._popup.depth === 0 && this._popup.frameId === 0) {
+ apiBroadcastTab('rootPopupInformation', {popupId: this._popup.id, frameId: this._popup.frameId});
}
}
_broadcastDocumentInformation(uniqueId) {
apiBroadcastTab('documentInformationBroadcast', {
uniqueId,
- frameId: this.popup.frameId,
+ frameId: this._popup.frameId,
title: document.title
});
}
-
- _canEnable() {
- return this.popup.depth <= this.options.scanning.popupNestingMaxDepth && !this._disabledOverride;
- }
-
- async _updatePopupPosition() {
- const textSource = this.getCurrentTextSource();
- if (textSource !== null && await this.popup.isVisible()) {
- this._showPopupContent(textSource);
- }
- }
-
- static _getVisualViewportScale() {
- const visualViewport = window.visualViewport;
- return visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0;
- }
}
diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-factory.js
index 958462ff..b10acbaf 100644
--- a/ext/fg/js/popup-proxy-host.js
+++ b/ext/fg/js/popup-factory.js
@@ -18,35 +18,31 @@
/* global
* FrontendApiReceiver
* Popup
- * apiFrameInformationGet
*/
-class PopupProxyHost {
- constructor() {
+class PopupFactory {
+ constructor(frameId) {
this._popups = new Map();
- this._apiReceiver = null;
- this._frameId = null;
+ this._frameId = frameId;
}
// Public functions
async prepare() {
- const {frameId} = await apiFrameInformationGet();
- if (typeof frameId !== 'number') { return; }
- this._frameId = frameId;
-
- this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${this._frameId}`, new Map([
- ['getOrCreatePopup', this._onApiGetOrCreatePopup.bind(this)],
- ['setOptions', this._onApiSetOptions.bind(this)],
- ['hide', this._onApiHide.bind(this)],
- ['isVisible', this._onApiIsVisibleAsync.bind(this)],
- ['setVisibleOverride', this._onApiSetVisibleOverride.bind(this)],
- ['containsPoint', this._onApiContainsPoint.bind(this)],
- ['showContent', this._onApiShowContent.bind(this)],
- ['setCustomCss', this._onApiSetCustomCss.bind(this)],
- ['clearAutoPlayTimer', this._onApiClearAutoPlayTimer.bind(this)],
- ['setContentScale', this._onApiSetContentScale.bind(this)]
+ const apiReceiver = new FrontendApiReceiver(`popup-factory#${this._frameId}`, new Map([
+ ['getOrCreatePopup', {async: false, handler: this._onApiGetOrCreatePopup.bind(this)}],
+ ['setOptionsContext', {async: true, handler: this._onApiSetOptionsContext.bind(this)}],
+ ['hide', {async: false, handler: this._onApiHide.bind(this)}],
+ ['isVisible', {async: true, handler: this._onApiIsVisibleAsync.bind(this)}],
+ ['setVisibleOverride', {async: true, handler: this._onApiSetVisibleOverride.bind(this)}],
+ ['containsPoint', {async: true, handler: this._onApiContainsPoint.bind(this)}],
+ ['showContent', {async: true, handler: this._onApiShowContent.bind(this)}],
+ ['setCustomCss', {async: false, handler: this._onApiSetCustomCss.bind(this)}],
+ ['clearAutoPlayTimer', {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}],
+ ['setContentScale', {async: false, handler: this._onApiSetContentScale.bind(this)}],
+ ['getUrl', {async: false, handler: this._onApiGetUrl.bind(this)}]
]));
+ apiReceiver.prepare();
}
getOrCreatePopup(id=null, parentId=null, depth=null) {
@@ -91,24 +87,25 @@ class PopupProxyHost {
popup.setParent(parent);
}
this._popups.set(id, popup);
+ popup.prepare();
return popup;
}
// API message handlers
- async _onApiGetOrCreatePopup({id, parentId}) {
+ _onApiGetOrCreatePopup({id, parentId}) {
const popup = this.getOrCreatePopup(id, parentId);
return {
id: popup.id
};
}
- async _onApiSetOptions({id, options}) {
+ async _onApiSetOptionsContext({id, optionsContext, source}) {
const popup = this._getPopup(id);
- return await popup.setOptions(options);
+ return await popup.setOptionsContext(optionsContext, source);
}
- async _onApiHide({id, changeFocus}) {
+ _onApiHide({id, changeFocus}) {
const popup = this._getPopup(id);
return popup.hide(changeFocus);
}
@@ -125,32 +122,36 @@ class PopupProxyHost {
async _onApiContainsPoint({id, x, y}) {
const popup = this._getPopup(id);
- [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, x, y);
+ [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y);
return await popup.containsPoint(x, y);
}
- async _onApiShowContent({id, elementRect, writingMode, type, details}) {
+ async _onApiShowContent({id, elementRect, writingMode, type, details, context}) {
const popup = this._getPopup(id);
- elementRect = PopupProxyHost._convertJsonRectToDOMRect(popup, elementRect);
- if (!PopupProxyHost._popupCanShow(popup)) { return; }
- return await popup.showContent(elementRect, writingMode, type, details);
+ elementRect = this._convertJsonRectToDOMRect(popup, elementRect);
+ if (!this._popupCanShow(popup)) { return; }
+ return await popup.showContent(elementRect, writingMode, type, details, context);
}
- async _onApiSetCustomCss({id, css}) {
+ _onApiSetCustomCss({id, css}) {
const popup = this._getPopup(id);
return popup.setCustomCss(css);
}
- async _onApiClearAutoPlayTimer({id}) {
+ _onApiClearAutoPlayTimer({id}) {
const popup = this._getPopup(id);
return popup.clearAutoPlayTimer();
}
- async _onApiSetContentScale({id, scale}) {
+ _onApiSetContentScale({id, scale}) {
const popup = this._getPopup(id);
return popup.setContentScale(scale);
}
+ _onApiGetUrl() {
+ return window.location.href;
+ }
+
// Private functions
_getPopup(id) {
@@ -161,21 +162,21 @@ class PopupProxyHost {
return popup;
}
- static _convertJsonRectToDOMRect(popup, jsonRect) {
- const [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y);
+ _convertJsonRectToDOMRect(popup, jsonRect) {
+ const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y);
return new DOMRect(x, y, jsonRect.width, jsonRect.height);
}
- static _convertPopupPointToRootPagePoint(popup, x, y) {
+ _convertPopupPointToRootPagePoint(popup, x, y) {
if (popup.parent !== null) {
- const popupRect = popup.parent.getContainerRect();
+ const popupRect = popup.parent.getFrameRect();
x += popupRect.x;
y += popupRect.y;
}
return [x, y];
}
- static _popupCanShow(popup) {
+ _popupCanShow(popup) {
return popup.parent === null || popup.parent.isVisibleSync();
}
}
diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js
index 82ad9a8f..82da839a 100644
--- a/ext/fg/js/popup-proxy.js
+++ b/ext/fg/js/popup-proxy.js
@@ -20,14 +20,13 @@
*/
class PopupProxy {
- constructor(id, depth, parentId, parentFrameId, url, getFrameOffset=null) {
- this._parentId = parentId;
- this._parentFrameId = parentFrameId;
+ constructor(id, depth, parentPopupId, parentFrameId, getFrameOffset=null, setDisabled=null) {
this._id = id;
this._depth = depth;
- this._url = url;
- this._apiSender = new FrontendApiSender();
+ this._parentPopupId = parentPopupId;
+ this._apiSender = new FrontendApiSender(`popup-factory#${parentFrameId}`);
this._getFrameOffset = getFrameOffset;
+ this._setDisabled = setDisabled;
this._frameOffset = null;
this._frameOffsetPromise = null;
@@ -48,14 +47,10 @@ class PopupProxy {
return this._depth;
}
- get url() {
- return this._url;
- }
-
// Public functions
async prepare() {
- const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId});
+ const {id} = await this._invoke('getOrCreatePopup', {id: this._id, parentId: this._parentPopupId});
this._id = id;
}
@@ -63,20 +58,20 @@ class PopupProxy {
return true;
}
- async setOptions(options) {
- return await this._invokeHostApi('setOptions', {id: this._id, options});
+ async setOptionsContext(optionsContext, source) {
+ return await this._invoke('setOptionsContext', {id: this._id, optionsContext, source});
}
hide(changeFocus) {
- this._invokeHostApi('hide', {id: this._id, changeFocus});
+ this._invoke('hide', {id: this._id, changeFocus});
}
async isVisible() {
- return await this._invokeHostApi('isVisible', {id: this._id});
+ return await this._invoke('isVisible', {id: this._id});
}
setVisibleOverride(visible) {
- this._invokeHostApi('setVisibleOverride', {id: this._id, visible});
+ this._invoke('setVisibleOverride', {id: this._id, visible});
}
async containsPoint(x, y) {
@@ -84,38 +79,39 @@ class PopupProxy {
await this._updateFrameOffset();
[x, y] = this._applyFrameOffset(x, y);
}
- return await this._invokeHostApi('containsPoint', {id: this._id, x, y});
+ return await this._invoke('containsPoint', {id: this._id, x, y});
}
- async showContent(elementRect, writingMode, type=null, details=null) {
+ async showContent(elementRect, writingMode, type, details, context) {
let {x, y, width, height} = elementRect;
if (this._getFrameOffset !== null) {
await this._updateFrameOffset();
[x, y] = this._applyFrameOffset(x, y);
}
elementRect = {x, y, width, height};
- return await this._invokeHostApi('showContent', {id: this._id, elementRect, writingMode, type, details});
+ return await this._invoke('showContent', {id: this._id, elementRect, writingMode, type, details, context});
}
- async setCustomCss(css) {
- return await this._invokeHostApi('setCustomCss', {id: this._id, css});
+ setCustomCss(css) {
+ this._invoke('setCustomCss', {id: this._id, css});
}
clearAutoPlayTimer() {
- this._invokeHostApi('clearAutoPlayTimer', {id: this._id});
+ this._invoke('clearAutoPlayTimer', {id: this._id});
+ }
+
+ setContentScale(scale) {
+ this._invoke('setContentScale', {id: this._id, scale});
}
- async setContentScale(scale) {
- this._invokeHostApi('setContentScale', {id: this._id, scale});
+ async getUrl() {
+ return await this._invoke('getUrl', {});
}
// Private
- _invokeHostApi(action, params={}) {
- if (typeof this._parentFrameId !== 'number') {
- return Promise.reject(new Error('Invalid frame'));
- }
- return this._apiSender.invoke(action, params, `popup-proxy-host#${this._parentFrameId}`);
+ _invoke(action, params={}) {
+ return this._apiSender.invoke(action, params);
}
async _updateFrameOffset() {
@@ -142,9 +138,13 @@ class PopupProxy {
try {
const offset = await this._frameOffsetPromise;
this._frameOffset = offset !== null ? offset : [0, 0];
+ if (offset === null && this._setDisabled !== null) {
+ this._setDisabled();
+ return;
+ }
this._frameOffsetUpdatedAt = now;
} catch (e) {
- logError(e);
+ yomichan.logError(e);
} finally {
this._frameOffsetPromise = null;
}
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
index 42f08afa..b7d4b57e 100644
--- a/ext/fg/js/popup.js
+++ b/ext/fg/js/popup.js
@@ -16,8 +16,9 @@
*/
/* global
- * apiGetMessageToken
- * apiInjectStylesheet
+ * DOM
+ * apiOptionsGet
+ * dynamicLoader
*/
class Popup {
@@ -29,24 +30,24 @@ class Popup {
this._child = null;
this._childrenSupported = true;
this._injectPromise = null;
+ this._injectPromiseComplete = false;
this._visible = false;
this._visibleOverride = null;
this._options = null;
+ this._optionsContext = null;
this._contentScale = 1.0;
- this._containerSizeContentScale = null;
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
- this._messageToken = null;
+ this._previousOptionsContextSource = null;
- this._container = document.createElement('iframe');
- this._container.className = 'yomichan-float';
- this._container.addEventListener('mousedown', (e) => e.stopPropagation());
- this._container.addEventListener('scroll', (e) => e.stopPropagation());
- this._container.style.width = '0px';
- this._container.style.height = '0px';
+ this._frameSizeContentScale = null;
+ this._frameSecret = null;
+ this._frameToken = null;
+ this._frame = document.createElement('iframe');
+ this._frame.className = 'yomichan-float';
+ this._frame.style.width = '0';
+ this._frame.style.height = '0';
this._fullscreenEventListeners = new EventListenerCollection();
-
- this._updateVisibility();
}
// Public properties
@@ -71,19 +72,27 @@ class Popup {
return this._frameId;
}
- get url() {
- return window.location.href;
- }
-
// Public functions
+ prepare() {
+ this._updateVisibility();
+ this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
+ this._frame.addEventListener('scroll', (e) => e.stopPropagation());
+ this._frame.addEventListener('load', this._onFrameLoad.bind(this));
+ }
+
isProxy() {
return false;
}
- async setOptions(options) {
- this._options = options;
+ async setOptionsContext(optionsContext, source) {
+ this._optionsContext = optionsContext;
+ this._previousOptionsContextSource = source;
+
+ this._options = await apiOptionsGet(optionsContext);
this.updateTheme();
+
+ this._invokeApi('setOptionsContext', {optionsContext});
}
hide(changeFocus) {
@@ -111,7 +120,7 @@ class Popup {
async containsPoint(x, y) {
for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup._child) {
- const rect = popup._container.getBoundingClientRect();
+ const rect = popup._frame.getBoundingClientRect();
if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
return true;
}
@@ -119,14 +128,20 @@ class Popup {
return false;
}
- async showContent(elementRect, writingMode, type=null, details=null) {
+ async showContent(elementRect, writingMode, type, details, context) {
if (this._options === null) { throw new Error('Options not assigned'); }
+
+ const {optionsContext, source} = context;
+ if (source !== this._previousOptionsContextSource) {
+ await this.setOptionsContext(optionsContext, source);
+ }
+
await this._show(elementRect, writingMode);
if (type === null) { return; }
this._invokeApi('setContent', {type, details});
}
- async setCustomCss(css) {
+ setCustomCss(css) {
this._invokeApi('setCustomCss', {css});
}
@@ -160,82 +175,218 @@ class Popup {
}
updateTheme() {
- this._container.dataset.yomichanTheme = this._options.general.popupOuterTheme;
- this._container.dataset.yomichanSiteColor = this._getSiteColor();
+ this._frame.dataset.yomichanTheme = this._options.general.popupOuterTheme;
+ this._frame.dataset.yomichanSiteColor = this._getSiteColor();
}
async setCustomOuterCss(css, useWebExtensionApi) {
- return await Popup._injectStylesheet(
- 'yomichan-popup-outer-user-stylesheet',
- 'code',
- css,
- useWebExtensionApi
- );
+ return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi);
}
setChildrenSupported(value) {
this._childrenSupported = value;
}
- getContainer() {
- return this._container;
+ getFrame() {
+ return this._frame;
}
- getContainerRect() {
- return this._container.getBoundingClientRect();
+ getFrameRect() {
+ return this._frame.getBoundingClientRect();
}
// Private functions
_inject() {
- if (this._injectPromise === null) {
- this._injectPromise = this._createInjectPromise();
+ let injectPromise = this._injectPromise;
+ if (injectPromise === null) {
+ injectPromise = this._createInjectPromise();
+ this._injectPromise = injectPromise;
+ injectPromise.then(
+ () => {
+ if (injectPromise !== this._injectPromise) { return; }
+ this._injectPromiseComplete = true;
+ },
+ () => { this._resetFrame(); }
+ );
}
- return this._injectPromise;
+ return injectPromise;
+ }
+
+ _initializeFrame(frame, targetOrigin, frameId, setupFrame, timeout=10000) {
+ return new Promise((resolve, reject) => {
+ const tokenMap = new Map();
+ let timer = null;
+ let frameLoadedResolve = null;
+ let frameLoadedReject = null;
+ const frameLoaded = new Promise((resolve2, reject2) => {
+ frameLoadedResolve = resolve2;
+ frameLoadedReject = reject2;
+ });
+
+ const postMessage = (action, params) => {
+ const contentWindow = frame.contentWindow;
+ if (contentWindow === null) { throw new Error('Frame missing content window'); }
+
+ let validOrigin = true;
+ try {
+ validOrigin = (contentWindow.location.origin === targetOrigin);
+ } catch (e) {
+ // NOP
+ }
+ if (!validOrigin) { throw new Error('Unexpected frame origin'); }
+
+ contentWindow.postMessage({action, params}, targetOrigin);
+ };
+
+ const onMessage = (message) => {
+ onMessageInner(message);
+ return false;
+ };
+
+ const onMessageInner = async (message) => {
+ try {
+ if (!isObject(message)) { return; }
+ const {action, params} = message;
+ if (!isObject(params)) { return; }
+ await frameLoaded;
+ if (timer === null) { return; } // Done
+
+ switch (action) {
+ case 'popupPrepared':
+ {
+ const {secret} = params;
+ const token = yomichan.generateId(16);
+ tokenMap.set(secret, token);
+ postMessage('initialize', {secret, token, frameId});
+ }
+ break;
+ case 'popupInitialized':
+ {
+ const {secret, token} = params;
+ const token2 = tokenMap.get(secret);
+ if (typeof token2 !== 'undefined' && token === token2) {
+ cleanup();
+ resolve({secret, token});
+ }
+ }
+ break;
+ }
+ } catch (e) {
+ cleanup();
+ reject(e);
+ }
+ };
+
+ const onLoad = () => {
+ if (frameLoadedResolve === null) {
+ cleanup();
+ reject(new Error('Unexpected load event'));
+ return;
+ }
+
+ if (Popup.isFrameAboutBlank(frame)) {
+ return;
+ }
+
+ frameLoadedResolve();
+ frameLoadedResolve = null;
+ frameLoadedReject = null;
+ };
+
+ const cleanup = () => {
+ if (timer === null) { return; } // Done
+ clearTimeout(timer);
+ timer = null;
+
+ frameLoadedResolve = null;
+ if (frameLoadedReject !== null) {
+ frameLoadedReject(new Error('Terminated'));
+ frameLoadedReject = null;
+ }
+
+ chrome.runtime.onMessage.removeListener(onMessage);
+ frame.removeEventListener('load', onLoad);
+ };
+
+ // Start
+ timer = setTimeout(() => {
+ cleanup();
+ reject(new Error('Timeout'));
+ }, timeout);
+
+ chrome.runtime.onMessage.addListener(onMessage);
+ frame.addEventListener('load', onLoad);
+
+ // Prevent unhandled rejections
+ frameLoaded.catch(() => {}); // NOP
+
+ setupFrame(frame);
+ });
}
async _createInjectPromise() {
- if (this._messageToken === null) {
- this._messageToken = await apiGetMessageToken();
- }
+ this._injectStyles();
+
+ const {secret, token} = await this._initializeFrame(this._frame, this._targetOrigin, this._frameId, (frame) => {
+ frame.removeAttribute('src');
+ frame.removeAttribute('srcdoc');
+ this._observeFullscreen(true);
+ this._onFullscreenChanged();
+ frame.contentDocument.location.href = chrome.runtime.getURL('/fg/float.html');
+ });
+ this._frameSecret = secret;
+ this._frameToken = token;
+ // Configure
+ const messageId = yomichan.generateId(16);
const popupPreparedPromise = yomichan.getTemporaryListenerResult(
chrome.runtime.onMessage,
- ({action, params}, {resolve}) => {
+ (message, {resolve}) => {
if (
- action === 'popupPrepareCompleted' &&
- isObject(params) &&
- params.targetPopupId === this._id
+ isObject(message) &&
+ message.action === 'popupConfigured' &&
+ isObject(message.params) &&
+ message.params.messageId === messageId
) {
resolve();
}
}
);
-
- const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null);
- this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));
- this._container.addEventListener('load', () => {
- this._invokeApi('prepare', {
- popupInfo: {
- id: this._id,
- depth: this._depth,
- parentFrameId
- },
- url: this.url,
- childrenSupported: this._childrenSupported,
- scale: this._contentScale
- });
+ this._invokeApi('configure', {
+ messageId,
+ frameId: this._frameId,
+ popupId: this._id,
+ optionsContext: this._optionsContext,
+ childrenSupported: this._childrenSupported,
+ scale: this._contentScale
});
- this._observeFullscreen(true);
- this._onFullscreenChanged();
- this._injectStyles();
return popupPreparedPromise;
}
+ _onFrameLoad() {
+ if (!this._injectPromiseComplete) { return; }
+ this._resetFrame();
+ }
+
+ _resetFrame() {
+ const parent = this._frame.parentNode;
+ if (parent !== null) {
+ parent.removeChild(this._frame);
+ }
+ this._frame.removeAttribute('src');
+ this._frame.removeAttribute('srcdoc');
+
+ this._frameSecret = null;
+ this._frameToken = null;
+ this._injectPromise = null;
+ this._injectPromiseComplete = false;
+ }
+
async _injectStyles() {
try {
- await Popup._injectStylesheet('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true);
+ await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true);
} catch (e) {
// NOP
}
@@ -271,9 +422,9 @@ class Popup {
}
_onFullscreenChanged() {
- const parent = (Popup._getFullscreenElement() || document.body || null);
- if (parent !== null && this._container.parentNode !== parent) {
- parent.appendChild(this._container);
+ const parent = this._getFrameParentElement();
+ if (parent !== null && this._frame.parentNode !== parent) {
+ parent.appendChild(this._frame);
}
}
@@ -281,31 +432,31 @@ class Popup {
await this._inject();
const optionsGeneral = this._options.general;
- const container = this._container;
- const containerRect = container.getBoundingClientRect();
- const getPosition = (
- writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ?
- Popup._getPositionForHorizontalText :
- Popup._getPositionForVerticalText
- );
+ const frame = this._frame;
+ const frameRect = frame.getBoundingClientRect();
- const viewport = Popup._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport);
+ const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport);
const scale = this._contentScale;
- const scaleRatio = this._containerSizeContentScale === null ? 1.0 : scale / this._containerSizeContentScale;
- this._containerSizeContentScale = scale;
- let [x, y, width, height, below] = getPosition(
+ const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale;
+ this._frameSizeContentScale = scale;
+ const getPositionArgs = [
elementRect,
- Math.max(containerRect.width * scaleRatio, optionsGeneral.popupWidth * scale),
- Math.max(containerRect.height * scaleRatio, optionsGeneral.popupHeight * scale),
+ Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale),
+ Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale),
viewport,
scale,
optionsGeneral,
writingMode
+ ];
+ let [x, y, width, height, below] = (
+ writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ?
+ this._getPositionForHorizontalText(...getPositionArgs) :
+ this._getPositionForVerticalText(...getPositionArgs)
);
const fullWidth = (optionsGeneral.popupDisplayMode === 'full-width');
- container.classList.toggle('yomichan-float-full-width', fullWidth);
- container.classList.toggle('yomichan-float-above', !below);
+ frame.classList.toggle('yomichan-float-full-width', fullWidth);
+ frame.classList.toggle('yomichan-float-above', !below);
if (optionsGeneral.popupDisplayMode === 'full-width') {
x = viewport.left;
@@ -313,10 +464,10 @@ class Popup {
width = viewport.right - viewport.left;
}
- container.style.left = `${x}px`;
- container.style.top = `${y}px`;
- container.style.width = `${width}px`;
- container.style.height = `${height}px`;
+ frame.style.left = `${x}px`;
+ frame.style.top = `${y}px`;
+ frame.style.width = `${width}px`;
+ frame.style.height = `${height}px`;
this._setVisible(true);
if (this._child !== null) {
@@ -330,20 +481,20 @@ class Popup {
}
_updateVisibility() {
- this._container.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important');
+ this._frame.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important');
}
_focusParent() {
if (this._parent !== null) {
// Chrome doesn't like focusing iframe without contentWindow.
- const contentWindow = this._parent._container.contentWindow;
+ const contentWindow = this._parent.getFrame().contentWindow;
if (contentWindow !== null) {
contentWindow.focus();
}
} else {
// Firefox doesn't like focusing window without first blurring the iframe.
- // this.container.contentWindow.blur() doesn't work on Firefox for some reason.
- this._container.blur();
+ // this._frame.contentWindow.blur() doesn't work on Firefox for some reason.
+ this._frame.blur();
// This is needed for Chrome.
window.focus();
}
@@ -351,36 +502,52 @@ class Popup {
_getSiteColor() {
const color = [255, 255, 255];
- Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.documentElement).backgroundColor));
- Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.body).backgroundColor));
+ const {documentElement, body} = document;
+ if (documentElement !== null) {
+ this._addColor(color, window.getComputedStyle(documentElement).backgroundColor);
+ }
+ if (body !== null) {
+ this._addColor(color, window.getComputedStyle(body).backgroundColor);
+ }
const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
return dark ? 'dark' : 'light';
}
_invokeApi(action, params={}) {
- const token = this._messageToken;
- const contentWindow = this._container.contentWindow;
- if (token === null || contentWindow === null) { return; }
+ const secret = this._frameSecret;
+ const token = this._frameToken;
+ const contentWindow = this._frame.contentWindow;
+ if (secret === null || token === null || contentWindow === null) { return; }
- contentWindow.postMessage({action, params, token}, this._targetOrigin);
+ contentWindow.postMessage({action, params, secret, token}, this._targetOrigin);
}
- static _getFullscreenElement() {
- return (
- document.fullscreenElement ||
- document.msFullscreenElement ||
- document.mozFullScreenElement ||
- document.webkitFullscreenElement ||
- null
- );
+ _getFrameParentElement() {
+ const defaultParent = document.body;
+ const fullscreenElement = DOM.getFullscreenElement();
+ if (
+ fullscreenElement === null ||
+ fullscreenElement.shadowRoot ||
+ fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
+ ) {
+ return defaultParent;
+ }
+
+ switch (fullscreenElement.nodeName.toUpperCase()) {
+ case 'IFRAME':
+ case 'FRAME':
+ return defaultParent;
+ }
+
+ return fullscreenElement;
}
- static _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) {
+ _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) {
const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below');
const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale;
const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale;
- const [x, w] = Popup._getConstrainedPosition(
+ const [x, w] = this._getConstrainedPosition(
elementRect.right - horizontalOffset,
elementRect.left + horizontalOffset,
width,
@@ -388,7 +555,7 @@ class Popup {
viewport.right,
true
);
- const [y, h, below] = Popup._getConstrainedPositionBinary(
+ const [y, h, below] = this._getConstrainedPositionBinary(
elementRect.top - verticalOffset,
elementRect.bottom + verticalOffset,
height,
@@ -399,12 +566,12 @@ class Popup {
return [x, y, w, h, below];
}
- static _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) {
- const preferRight = Popup._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode);
+ _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) {
+ const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode);
const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale;
const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale;
- const [x, w] = Popup._getConstrainedPositionBinary(
+ const [x, w] = this._getConstrainedPositionBinary(
elementRect.left - horizontalOffset,
elementRect.right + horizontalOffset,
width,
@@ -412,7 +579,7 @@ class Popup {
viewport.right,
preferRight
);
- const [y, h, below] = Popup._getConstrainedPosition(
+ const [y, h, below] = this._getConstrainedPosition(
elementRect.bottom - verticalOffset,
elementRect.top + verticalOffset,
height,
@@ -423,20 +590,22 @@ class Popup {
return [x, y, w, h, below];
}
- static _isVerticalTextPopupOnRight(positionPreference, writingMode) {
+ _isVerticalTextPopupOnRight(positionPreference, writingMode) {
switch (positionPreference) {
case 'before':
- return !Popup._isWritingModeLeftToRight(writingMode);
+ return !this._isWritingModeLeftToRight(writingMode);
case 'after':
- return Popup._isWritingModeLeftToRight(writingMode);
+ return this._isWritingModeLeftToRight(writingMode);
case 'left':
return false;
case 'right':
return true;
+ default:
+ return false;
}
}
- static _isWritingModeLeftToRight(writingMode) {
+ _isWritingModeLeftToRight(writingMode) {
switch (writingMode) {
case 'vertical-lr':
case 'sideways-lr':
@@ -446,7 +615,7 @@ class Popup {
}
}
- static _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
+ _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
size = Math.min(size, maxLimit - minLimit);
let position;
@@ -461,7 +630,7 @@ class Popup {
return [position, size, after];
}
- static _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
+ _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
const overflowBefore = minLimit - (positionBefore - size);
const overflowAfter = (positionAfter + size) - maxLimit;
@@ -481,7 +650,10 @@ class Popup {
return [position, size, after];
}
- static _addColor(target, color) {
+ _addColor(target, cssColor) {
+ if (typeof cssColor !== 'string') { return; }
+
+ const color = this._getColorInfo(cssColor);
if (color === null) { return; }
const a = color[3];
@@ -493,7 +665,7 @@ class Popup {
}
}
- static _getColorInfo(cssColor) {
+ _getColorInfo(cssColor) {
const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor);
if (m === null) { return null; }
@@ -506,7 +678,7 @@ class Popup {
];
}
- static _getViewport(useVisualViewport) {
+ _getViewport(useVisualViewport) {
const visualViewport = window.visualViewport;
if (visualViewport !== null && typeof visualViewport === 'object') {
const left = visualViewport.offsetLeft;
@@ -531,87 +703,23 @@ class Popup {
}
}
+ const body = document.body;
return {
left: 0,
top: 0,
- right: document.body.clientWidth,
+ right: (body !== null ? body.clientWidth : 0),
bottom: window.innerHeight
};
}
- static _isOnExtensionPage() {
+ static isFrameAboutBlank(frame) {
try {
- const url = chrome.runtime.getURL('/');
- return window.location.href.substring(0, url.length) === url;
+ const contentDocument = frame.contentDocument;
+ if (contentDocument === null) { return false; }
+ const url = contentDocument.location.href;
+ return /^about:blank(?:[#?]|$)/.test(url);
} catch (e) {
- // NOP
- }
- }
-
- static async _injectStylesheet(id, type, value, useWebExtensionApi) {
- const injectedStylesheets = Popup._injectedStylesheets;
-
- if (Popup._isOnExtensionPage()) {
- // Permissions error will occur if trying to use the WebExtension API to inject
- // into an extension page.
- useWebExtensionApi = false;
- }
-
- let styleNode = injectedStylesheets.get(id);
- if (typeof styleNode !== 'undefined') {
- if (styleNode === null) {
- // Previously injected via WebExtension API
- throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`);
- }
- } else {
- styleNode = null;
+ return false;
}
-
- if (useWebExtensionApi) {
- // Inject via WebExtension API
- if (styleNode !== null && styleNode.parentNode !== null) {
- styleNode.parentNode.removeChild(styleNode);
- }
-
- await apiInjectStylesheet(type, value);
-
- injectedStylesheets.set(id, null);
- return null;
- }
-
- // Create node in document
- const parentNode = document.head;
- if (parentNode === null) {
- throw new Error('No parent node');
- }
-
- // Create or reuse node
- const isFile = (type === 'file');
- const tagName = isFile ? 'link' : 'style';
- if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) {
- if (styleNode !== null && styleNode.parentNode !== null) {
- styleNode.parentNode.removeChild(styleNode);
- }
- styleNode = document.createElement(tagName);
- styleNode.id = id;
- }
-
- // Update node style
- if (isFile) {
- styleNode.rel = value;
- } else {
- styleNode.textContent = value;
- }
-
- // Update parent
- if (styleNode.parentNode !== parentNode) {
- parentNode.appendChild(styleNode);
- }
-
- // Add to map
- injectedStylesheets.set(id, styleNode);
- return styleNode;
}
}
-
-Popup._injectedStylesheets = new Map();
diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js
index 3d9afe0f..fa4706f2 100644
--- a/ext/fg/js/source.js
+++ b/ext/fg/js/source.js
@@ -46,10 +46,14 @@ class TextSourceRange {
return this.content;
}
- setEndOffset(length) {
- const state = TextSourceRange.seekForward(this.range.startContainer, this.range.startOffset, length);
+ setEndOffset(length, fromEnd=false) {
+ const state = (
+ fromEnd ?
+ TextSourceRange.seekForward(this.range.endContainer, this.range.endOffset, length) :
+ TextSourceRange.seekForward(this.range.startContainer, this.range.startOffset, length)
+ );
this.range.setEnd(state.node, state.offset);
- this.content = state.content;
+ this.content = (fromEnd ? this.content + state.content : state.content);
return length - state.remainder;
}
@@ -57,7 +61,7 @@ class TextSourceRange {
const state = TextSourceRange.seekBackward(this.range.startContainer, this.range.startOffset, length);
this.range.setStart(state.node, state.offset);
this.rangeStartOffset = this.range.startOffset;
- this.content = state.content;
+ this.content = state.content + this.content;
return length - state.remainder;
}
@@ -94,7 +98,15 @@ class TextSourceRange {
this.rangeStartOffset === other.rangeStartOffset
);
} else {
- return this.range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
+ try {
+ return this.range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
+ } catch (e) {
+ if (e.name === 'WrongDocumentError') {
+ // This can happen with shadow DOMs if the ranges are in different documents.
+ return false;
+ }
+ throw e;
+ }
}
}
@@ -110,7 +122,8 @@ class TextSourceRange {
return !(
style.visibility === 'hidden' ||
style.display === 'none' ||
- parseFloat(style.fontSize) === 0);
+ parseFloat(style.fontSize) === 0
+ );
}
static getRubyElement(node) {
@@ -345,13 +358,32 @@ class TextSourceRange {
*/
class TextSourceElement {
- constructor(element, content='') {
- this.element = element;
- this.content = content;
+ constructor(element, fullContent=null, startOffset=0, endOffset=0) {
+ this._element = element;
+ this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element));
+ this._startOffset = startOffset;
+ this._endOffset = endOffset;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ }
+
+ get element() {
+ return this._element;
+ }
+
+ get fullContent() {
+ return this._fullContent;
+ }
+
+ get startOffset() {
+ return this._startOffset;
+ }
+
+ get endOffset() {
+ return this._endOffset;
}
clone() {
- return new TextSourceElement(this.element, this.content);
+ return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset);
}
cleanup() {
@@ -359,44 +391,32 @@ class TextSourceElement {
}
text() {
- return this.content;
+ return this._content;
}
- setEndOffset(length) {
- switch (this.element.nodeName.toUpperCase()) {
- case 'BUTTON':
- this.content = this.element.textContent;
- break;
- case 'IMG':
- this.content = this.element.getAttribute('alt');
- break;
- default:
- this.content = this.element.value;
- break;
- }
-
- let consumed = 0;
- let content = '';
- for (const currentChar of this.content || '') {
- if (consumed >= length) {
- break;
- } else if (!currentChar.match(IGNORE_TEXT_PATTERN)) {
- consumed++;
- content += currentChar;
- }
+ setEndOffset(length, fromEnd=false) {
+ if (fromEnd) {
+ const delta = Math.min(this._fullContent.length - this._endOffset, length);
+ this._endOffset += delta;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ return delta;
+ } else {
+ const delta = Math.min(this._fullContent.length - this._startOffset, length);
+ this._endOffset = this._startOffset + delta;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ return delta;
}
-
- this.content = content;
-
- return this.content.length;
}
- setStartOffset() {
- return 0;
+ setStartOffset(length) {
+ const delta = Math.min(this._startOffset, length);
+ this._startOffset -= delta;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ return delta;
}
getRect() {
- return this.element.getBoundingClientRect();
+ return this._element.getBoundingClientRect();
}
getWritingMode() {
@@ -416,8 +436,30 @@ class TextSourceElement {
typeof other === 'object' &&
other !== null &&
other instanceof TextSourceElement &&
- other.element === this.element &&
- other.content === this.content
+ this._element === other.element &&
+ this._fullContent === other.fullContent &&
+ this._startOffset === other.startOffset &&
+ this._endOffset === other.endOffset
);
}
+
+ static getElementContent(element) {
+ let content;
+ switch (element.nodeName.toUpperCase()) {
+ case 'BUTTON':
+ content = element.textContent;
+ break;
+ case 'IMG':
+ content = element.getAttribute('alt') || '';
+ break;
+ default:
+ content = `${element.value}`;
+ break;
+ }
+
+ // Remove zero-width non-joiner
+ content = content.replace(/\u200c/g, '');
+
+ return content;
+ }
}
diff --git a/ext/manifest.json b/ext/manifest.json
index 041827a1..f908da89 100644
--- a/ext/manifest.json
+++ b/ext/manifest.json
@@ -1,12 +1,29 @@
{
"manifest_version": 2,
"name": "Yomichan (testing)",
- "version": "20.4.18.0",
+ "version": "20.5.22.0",
"description": "Japanese dictionary with Anki integration (testing)",
- "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"},
+ "icons": {
+ "16": "mixed/img/icon16.png",
+ "19": "mixed/img/icon19.png",
+ "32": "mixed/img/icon32.png",
+ "38": "mixed/img/icon38.png",
+ "48": "mixed/img/icon48.png",
+ "64": "mixed/img/icon48.png",
+ "128": "mixed/img/icon128.png"
+ },
"browser_action": {
- "default_icon": {"19": "mixed/img/icon19.png", "38": "mixed/img/icon38.png"},
+ "default_icon": {
+ "16": "mixed/img/icon16.png",
+ "19": "mixed/img/icon19.png",
+ "32": "mixed/img/icon32.png",
+ "38": "mixed/img/icon38.png",
+ "48": "mixed/img/icon48.png",
+ "64": "mixed/img/icon48.png",
+ "128": "mixed/img/icon128.png"
+ },
+ "default_title": "Yomichan",
"default_popup": "bg/context.html"
},
@@ -21,17 +38,18 @@
"mixed/js/core.js",
"mixed/js/dom.js",
"mixed/js/api.js",
+ "mixed/js/dynamic-loader.js",
"mixed/js/text-scanner.js",
"fg/js/document.js",
"fg/js/frontend-api-sender.js",
"fg/js/frontend-api-receiver.js",
"fg/js/popup.js",
"fg/js/source.js",
+ "fg/js/popup-factory.js",
"fg/js/frame-offset-forwarder.js",
"fg/js/popup-proxy.js",
- "fg/js/popup-proxy-host.js",
"fg/js/frontend.js",
- "fg/js/frontend-initialize.js"
+ "fg/js/content-script-main.js"
],
"match_about_blank": true,
"all_frames": true
diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css
deleted file mode 100644
index e4549bbf..00000000
--- a/ext/mixed/css/display-dark.css
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Copyright (C) 2019-2020 Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the entrys of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-
-body { background-color: #1e1e1e; color: #d4d4d4; }
-
-h2 { border-bottom-color: #2f2f2f; }
-
-.navigation-header {
- background-color: #1e1e1e;
- border-bottom-color: #2f2f2f;
-}
-
-.entry+.entry { border-top-color: #2f2f2f; }
-
-.kanji-glyph-data>tbody>tr>* { border-top-color: #3f3f3f; }
-
-.tag { color: #e1e1e1; }
-.tag[data-category=default] { background-color: #69696e; }
-.tag[data-category=name] { background-color: #489148; }
-.tag[data-category=expression] { background-color: #b07f39; }
-.tag[data-category=popular] { background-color: #025caa; }
-.tag[data-category=frequent] { background-color: #4490a7; }
-.tag[data-category=archaism] { background-color: #b04340; }
-.tag[data-category=dictionary] { background-color: #9057ad; }
-.tag[data-category=frequency] { background-color: #489148; }
-.tag[data-category=partOfSpeech] { background-color: #565656; }
-.tag[data-category=search] { background-color: #69696e; }
-.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; }
-
-.term-reasons { color: #888888; }
-
-.term-expression>.term-expression-text .kanji-link {
- border-bottom-color: #888888;
- color: #cccccc;
-}
-
-.term-expression[data-frequency=popular]>.term-expression-text,
-.term-expression[data-frequency=popular]>.term-expression-text .kanji-link {
- color: #0275d8;
-}
-
-.term-expression[data-frequency=rare]>.term-expression-text,
-.term-expression[data-frequency=rare]>.term-expression-text .kanji-link {
- color: #666666;
-}
-
-.term-definition-list,
-.term-pitch-accent-group-list,
-.term-pitch-accent-disambiguation-list,
-.kanji-glossary-list {
- color: #888888;
-}
-
-.term-glossary,
-.term-pitch-accent,
-.kanji-glossary {
- color: #d4d4d4;
-}
-
-.icon-checkbox:checked + label {
- /* invert colors */
- background-color: #d4d4d4;
- color: #1e1e1e;
-}
-
-.term-pitch-accent-container { border-bottom-color: #2f2f2f; }
-
-.term-pitch-accent-character:before { border-color: #ffffff; }
-
-.term-pitch-accent-graph-line,
-.term-pitch-accent-graph-line-tail,
-#term-pitch-accent-graph-dot,
-#term-pitch-accent-graph-dot-downstep,
-#term-pitch-accent-graph-triangle {
- stroke: #ffffff;
-}
-
-#term-pitch-accent-graph-dot,
-#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
- fill: #ffffff;
-}
diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css
deleted file mode 100644
index 7bcb1014..00000000
--- a/ext/mixed/css/display-default.css
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Copyright (C) 2019-2020 Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the entrys of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-
-body { background-color: #ffffff; color: #333333; }
-
-h2 { border-bottom-color: #eeeeee; }
-
-.navigation-header {
- background-color: #ffffff;
- border-bottom-color: #eeeeee;
-}
-
-.entry+.entry { border-top-color: #eeeeee; }
-
-.kanji-glyph-data>tbody>tr>* { border-top-color: #dddddd; }
-
-.tag { color: #ffffff; }
-.tag[data-category=default] { background-color: #8a8a91; }
-.tag[data-category=name] { background-color: #5cb85c; }
-.tag[data-category=expression] { background-color: #f0ad4e; }
-.tag[data-category=popular] { background-color: #0275d8; }
-.tag[data-category=frequent] { background-color: #5bc0de; }
-.tag[data-category=archaism] { background-color: #d9534f; }
-.tag[data-category=dictionary] { background-color: #aa66cc; }
-.tag[data-category=frequency] { background-color: #5cb85c; }
-.tag[data-category=partOfSpeech] { background-color: #565656; }
-.tag[data-category=search] { background-color: #8a8a91; }
-.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; }
-
-.term-reasons { color: #777777; }
-
-.term-expression>.term-expression-text .kanji-link {
- border-bottom-color: #777777;
- color: #333333;
-}
-
-.term-expression[data-frequency=popular]>.term-expression-text,
-.term-expression[data-frequency=popular]>.term-expression-text .kanji-link {
- color: #0275d8;
-}
-
-.term-expression[data-frequency=rare]>.term-expression-text,
-.term-expression[data-frequency=rare]>.term-expression-text .kanji-link {
- color: #999999;
-}
-
-.term-definition-list,
-.term-pitch-accent-group-list,
-.term-pitch-accent-disambiguation-list,
-.kanji-glossary-list {
- color: #777777;
-}
-
-.term-glossary,
-.term-pitch-accent,
-.kanji-glossary {
- color: #000000;
-}
-
-.icon-checkbox:checked + label {
- /* invert colors */
- background-color: #333333;
- color: #ffffff;
-}
-
-.term-pitch-accent-container { border-bottom-color: #eeeeee; }
-
-.term-pitch-accent-character:before { border-color: #000000; }
-
-.term-pitch-accent-graph-line,
-.term-pitch-accent-graph-line-tail,
-#term-pitch-accent-graph-dot,
-#term-pitch-accent-graph-dot-downstep,
-#term-pitch-accent-graph-triangle {
- stroke: #000000;
-}
-
-#term-pitch-accent-graph-dot,
-#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
- fill: #000000;
-}
diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css
index d1a54064..8b567173 100644
--- a/ext/mixed/css/display.css
+++ b/ext/mixed/css/display.css
@@ -15,6 +15,72 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+/*
+ * Variables
+ */
+
+:root {
+ --background-color: #ffffff;
+ --glossary-image-background-color: #eeeeee;
+
+ --dark-text-color: #000000;
+ --default-text-color: #333333;
+ --light-text-color: #777777;
+ --very-light-text-color: #999999;
+
+ --light-border-color: #eeeeee;
+ --medium-border-color: #dddddd;
+ --dark-border-color: #777777;
+
+ --popuplar-kanji-text-color: #0275d8;
+
+ --pitch-accent-annotation-color: #000000;
+
+ --tag-text-color: #ffffff;
+ --tag-default-background-color: #8a8a91;
+ --tag-name-background-color: #5cb85c;
+ --tag-expression-background-color: #f0ad4e;
+ --tag-popular-background-color: #0275d8;
+ --tag-frequent-background-color: #5bc0de;
+ --tag-archaism-background-color: #d9534f;
+ --tag-dictionary-background-color: #aa66cc;
+ --tag-frequency-background-color: #5cb85c;
+ --tag-part-of-speech-background-color: #565656;
+ --tag-search-background-color: #8a8a91;
+ --tag-pitch-accent-dictionary-background-color: #6640be;
+}
+
+:root[data-yomichan-theme=dark] {
+ --background-color: #1e1e1e;
+ --glossary-image-background-color: #2f2f2f;
+
+ --dark-text-color: #d8d8d8;
+ --default-text-color: #d4d4d4;
+ --light-text-color: #888888;
+ --very-light-text-color: #666666;
+
+ --light-border-color: #2f2f2f;
+ --medium-border-color: #3f3f3f;
+ --dark-border-color: #888888;
+
+ --popuplar-kanji-text-color: #0275d8;
+
+ --pitch-accent-annotation-color: #ffffff;
+
+ --tag-text-color: #e1e1e1;
+ --tag-default-background-color: #69696e;
+ --tag-name-background-color: #489148;
+ --tag-expression-background-color: #b07f39;
+ --tag-popular-background-color: #025caa;
+ --tag-frequent-background-color: #4490a7;
+ --tag-archaism-background-color: #b04340;
+ --tag-dictionary-background-color: #9057ad;
+ --tag-frequency-background-color: #489148;
+ --tag-part-of-speech-background-color: #565656;
+ --tag-search-background-color: #69696e;
+ --tag-pitch-accent-dictionary-background-color: #6640be;
+}
+
/*
* Fonts
@@ -25,6 +91,7 @@
src: url('/mixed/ttf/kanji-stroke-orders.ttf');
}
+
/*
* General
*/
@@ -45,6 +112,8 @@ body {
border: 0;
padding: 0;
overflow-y: scroll; /* always show scroll bar */
+ background-color: var(--background-color);
+ color: var(--default-text-color);
}
ol, ul {
@@ -68,10 +137,10 @@ h2 {
font-size: 1.25em;
font-weight: normal;
margin: 0.25em 0 0;
- border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */
- border-bottom-style: solid;
+ border-bottom: 0.05714285714285714em solid var(--light-border-color); /* 14px * 1.25em => 1px */
}
+
/*
* Navigation
*/
@@ -83,8 +152,8 @@ h2 {
height: 2.1em;
box-sizing: border-box;
padding: 0.25em 0.5em;
- border-bottom-width: 0.07142857em; /* 14px => 1px */
- border-bottom-style: solid;
+ border-bottom: 0.07142857em solid var(--light-border-color); /* 14px => 1px */
+ background-color: var(--background-color);
z-index: 10;
}
@@ -131,6 +200,12 @@ h2 {
user-select: none;
}
+.icon-checkbox:checked+label {
+ /* Invert colors */
+ background-color: var(--default-text-color);
+ color: var(--background-color);
+}
+
#query-parser-content {
margin-top: 0.5em;
font-size: 2em;
@@ -206,11 +281,21 @@ button.action-button {
}
.term-expression .kanji-link {
- border-bottom-width: 0.03571428em; /* 28px => 1px */
- border-bottom-style: dashed;
+ border-bottom: 0.03571428em dashed var(--dark-border-color); /* 28px => 1px */
+ color: var(--default-text-color);
text-decoration: none;
}
+.term-expression[data-frequency=popular]>.term-expression-text,
+.term-expression[data-frequency=popular]>.term-expression-text .kanji-link {
+ color: var(--popuplar-kanji-text-color);
+}
+
+.term-expression[data-frequency=rare]>.term-expression-text,
+.term-expression[data-frequency=rare]>.term-expression-text .kanji-link {
+ color: var(--very-light-text-color);
+}
+
.entry:not(.entry-current) .current {
display: none;
}
@@ -225,6 +310,48 @@ button.action-button {
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.25em;
+ color: var(--tag-text-color);
+ background-color: var(--tag-default-background-color);
+}
+
+.tag[data-category=name] {
+ background-color: var(--tag-name-background-color);
+}
+
+.tag[data-category=expression] {
+ background-color: var(--tag-expression-background-color);
+}
+
+.tag[data-category=popular] {
+ background-color: var(--tag-popular-background-color);
+}
+
+.tag[data-category=frequent] {
+ background-color: var(--tag-frequent-background-color);
+}
+
+.tag[data-category=archaism] {
+ background-color: var(--tag-archaism-background-color);
+}
+
+.tag[data-category=dictionary] {
+ background-color: var(--tag-dictionary-background-color);
+}
+
+.tag[data-category=frequency] {
+ background-color: var(--tag-frequency-background-color);
+}
+
+.tag[data-category=partOfSpeech] {
+ background-color: var(--tag-part-of-speech-background-color);
+}
+
+.tag[data-category=search] {
+ background-color: var(--tag-search-background-color);
+}
+
+.tag[data-category=pitch-accent-dictionary] {
+ background-color: var(--tag-pitch-accent-dictionary-background-color);
}
.tag-inner {
@@ -249,8 +376,7 @@ button.action-button {
}
.entry+.entry {
- border-top-width: 0.07142857em; /* 14px => 1px */
- border-top-style: solid;
+ border-top: 0.07142857em solid var(--light-border-color); /* 14px => 1px */
}
.entry[data-type=term][data-expression-multi=true] .actions>.action-play-audio {
@@ -259,6 +385,7 @@ button.action-button {
.term-reasons {
display: inline-block;
+ color: var(--light-text-color);
}
.term-reasons>.term-reason+.term-reason-separator+.term-reason:before {
@@ -346,6 +473,7 @@ button.action-button {
margin: 0;
padding: 0;
list-style-type: none;
+ color: var(--light-text-color);
}
.term-definition-list:not([data-count="0"]):not([data-count="1"]) {
@@ -364,6 +492,10 @@ button.action-button {
list-style-type: circle;
}
+.term-glossary {
+ color: var(--dark-text-color);
+}
+
.term-definition-disambiguation-list[data-count="0"] {
display: none;
}
@@ -445,8 +577,7 @@ button.action-button {
}
.term-pitch-accent-container {
- border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */
- border-bottom-style: solid;
+ border-bottom: 0.05714285714285714em solid var(--light-border-color); /* 14px * 1.25em => 1px */
padding-bottom: 0.25em;
margin-bottom: 0.25em;
}
@@ -455,6 +586,7 @@ button.action-button {
margin: 0;
padding: 0;
list-style-type: none;
+ color: var(--light-text-color);
}
.term-pitch-accent-group-list:not([data-count="0"]):not([data-count="1"]) {
@@ -478,6 +610,7 @@ button.action-button {
.term-pitch-accent {
display: inline;
line-height: 1.5em;
+ color: var(--dark-text-color);
}
.term-pitch-accent-list:not([data-count="0"]):not([data-count="1"])>.term-pitch-accent {
@@ -490,6 +623,7 @@ button.action-button {
.term-pitch-accent-disambiguation-list {
padding-right: 0.25em;
+ color: var(--light-text-color);
}
.term-pitch-accent-disambiguation-list:before {
@@ -522,6 +656,9 @@ button.action-button {
display: inline-block;
position: relative;
}
+.term-pitch-accent-character:before {
+ border-color: var(--pitch-accent-annotation-color);
+}
.term-pitch-accent-character[data-pitch='high']:before {
content: "";
display: block;
@@ -586,33 +723,155 @@ button.action-button {
.term-pitch-accent-graph-line,
.term-pitch-accent-graph-line-tail {
fill: none;
- stroke: #000000;
+ stroke: var(--pitch-accent-annotation-color);
stroke-width: 5;
}
.term-pitch-accent-graph-line-tail {
stroke-dasharray: 5 5;
}
#term-pitch-accent-graph-dot {
- fill: #000000;
- stroke: #000000;
+ fill: var(--pitch-accent-annotation-color);
+ stroke: var(--pitch-accent-annotation-color);
stroke-width: 5;
}
#term-pitch-accent-graph-dot-downstep {
fill: none;
- stroke: #000000;
+ stroke: var(--pitch-accent-annotation-color);
stroke-width: 5;
}
#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
- fill: #000000;
+ fill: var(--pitch-accent-annotation-color);
}
#term-pitch-accent-graph-triangle {
fill: none;
- stroke: #000000;
+ stroke: var(--pitch-accent-annotation-color);
stroke-width: 5;
}
/*
+ * Glossary images
+ */
+
+.term-glossary-image-container {
+ display: inline-block;
+ white-space: nowrap;
+ max-width: 100%;
+ position: relative;
+ vertical-align: top;
+ line-height: 0;
+ font-size: 0.07142857em; /* 14px => 1px */
+ overflow: hidden;
+ background-color: var(--glossary-image-background-color);
+}
+
+.term-glossary-image-link {
+ cursor: inherit;
+ color: inherit;
+}
+
+.term-glossary-image-link[href]:hover {
+ cursor: pointer;
+}
+
+.term-glossary-image-container-overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ font-size: 14em; /* 1px => 14px; */
+ line-height: 1.42857143; /* 14px => 20px */
+ display: table;
+ table-layout: fixed;
+ white-space: normal;
+ color: var(--light-text-color);
+}
+
+.term-glossary-item[data-has-image=true][data-image-load-state=load-error] .term-glossary-image-container-overlay:after {
+ content: "Image failed to load";
+ display: table-cell;
+ width: 100%;
+ height: 100%;
+ vertical-align: middle;
+ text-align: center;
+ padding: 0.25em;
+}
+
+.term-glossary-image {
+ display: inline-block;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ vertical-align: top;
+ object-fit: contain;
+ border: none;
+ outline: none;
+}
+
+.term-glossary-image:not([src]) {
+ display: none;
+}
+
+.term-glossary-image[data-pixelated=true] {
+ image-rendering: auto;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: -webkit-optimize-contrast;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+}
+
+.term-glossary-image-aspect-ratio-sizer {
+ content: "";
+ display: inline-block;
+ width: 0;
+ vertical-align: top;
+ font-size: 0;
+}
+
+.term-glossary-image-link-text:before {
+ content: "[";
+}
+
+.term-glossary-image-link-text:after {
+ content: "]";
+}
+
+:root[data-compact-glossaries=true] .term-glossary-image-container {
+ display: none;
+ position: absolute;
+ left: 0;
+ top: 100%;
+ z-index: 1;
+}
+
+:root[data-compact-glossaries=true] .entry:nth-last-of-type(1):not(:nth-of-type(1)) .term-glossary-image-container {
+ bottom: 100%;
+ top: auto;
+}
+
+:root[data-compact-glossaries=true] .term-glossary-image-link {
+ position: relative;
+ display: inline-block;
+}
+
+:root[data-compact-glossaries=true] .term-glossary-image-link:hover .term-glossary-image-container,
+:root[data-compact-glossaries=true] .term-glossary-image-link:focus .term-glossary-image-container {
+ display: block;
+}
+
+:root:not([data-compact-glossaries=true]) .term-glossary-image-link-text {
+ display: none;
+}
+
+:root:not([data-compact-glossaries=true]) .term-glossary-image-description {
+ display: block;
+}
+
+
+/*
* Kanji
*/
@@ -631,8 +890,7 @@ button.action-button {
}
.kanji-glyph-data>tbody>tr>* {
- border-top-width: 0.07142857em; /* 14px => 1px */
- border-top-style: solid;
+ border-top: 0.07142857em solid var(--medium-border-color); /* 14px => 1px */
text-align: left;
vertical-align: top;
padding: 0.36em;
@@ -668,9 +926,14 @@ button.action-button {
margin: 0;
padding: 0;
list-style-type: none;
+ color: var(--light-text-color);
}
.kanji-glossary-list:not([data-count="0"]):not([data-count="1"]) {
padding-left: 1.4em;
list-style-type: decimal;
}
+
+.kanji-glossary {
+ color: var(--dark-text-color);
+}
diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html
index 3baa8293..fc0558a9 100644
--- a/ext/mixed/display-templates.html
+++ b/ext/mixed/display-templates.html
@@ -35,6 +35,7 @@
</li></template>
<template id="term-definition-disambiguation-template"><span class="term-definition-disambiguation"></span></template>
<template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary-separator"> </span><span class="term-glossary"></span></li></template>
+<template id="term-glossary-item-image-template"><li class="term-glossary-item" data-has-image="true"><span class="term-glossary-separator"> </span><span class="term-glossary"><a class="term-glossary-image-link" target="_blank" rel="noreferrer noopener"><span class="term-glossary-image-container"><span class="term-glossary-image-aspect-ratio-sizer"></span><img class="term-glossary-image" alt="" /><span class="term-glossary-image-container-overlay"></span></span><span class="term-glossary-image-link-text">Image</span></a> <span class="term-glossary-image-description"></span></span></li></template>
<template id="term-reason-template"><span class="term-reason"></span><span class="term-reason-separator"> </span></template>
<template id="term-pitch-accent-static-template"><svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
diff --git a/ext/mixed/img/icon32.png b/ext/mixed/img/icon32.png
new file mode 100644
index 00000000..05f2f064
--- /dev/null
+++ b/ext/mixed/img/icon32.png
Binary files differ
diff --git a/ext/mixed/img/yomichan-icon.svg b/ext/mixed/img/yomichan-icon.svg
new file mode 100644
index 00000000..f15ab0aa
--- /dev/null
+++ b/ext/mixed/img/yomichan-icon.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+ <rect width="16" height="16" rx="1.625" ry="1.625"/>
+ <path d="m2 2v2h3v3h-3v2h3v3h-3v2h5v-12h-5zm7 0v2h5v-2h-5zm0 5v2h5v-2h-5zm0 5v2h5v-2h-5z" fill="#fff"/>
+</svg> \ No newline at end of file
diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js
index 30c08347..0bc91759 100644
--- a/ext/mixed/js/api.js
+++ b/ext/mixed/js/api.js
@@ -28,10 +28,6 @@ function apiOptionsGetFull() {
return _apiInvoke('optionsGetFull');
}
-function apiOptionsSet(changedOptions, optionsContext, source) {
- return _apiInvoke('optionsSet', {changedOptions, optionsContext, source});
-}
-
function apiOptionsSave(source) {
return _apiInvoke('optionsSave', {source});
}
@@ -64,8 +60,8 @@ function apiTemplateRender(template, data) {
return _apiInvoke('templateRender', {data, template});
}
-function apiAudioGetUri(definition, source, optionsContext) {
- return _apiInvoke('audioGetUri', {definition, source, optionsContext});
+function apiAudioGetUri(definition, source, details) {
+ return _apiInvoke('audioGetUri', {definition, source, details});
}
function apiCommandExec(command, params) {
@@ -76,6 +72,10 @@ function apiScreenshotGet(options) {
return _apiInvoke('screenshotGet', {options});
}
+function apiSendMessageToFrame(frameId, action, params) {
+ return _apiInvoke('sendMessageToFrame', {frameId, action, params});
+}
+
function apiBroadcastTab(action, params) {
return _apiInvoke('broadcastTab', {action, params});
}
@@ -108,14 +108,176 @@ function apiGetZoom() {
return _apiInvoke('getZoom');
}
-function apiGetMessageToken() {
- return _apiInvoke('getMessageToken');
-}
-
function apiGetDefaultAnkiFieldTemplates() {
return _apiInvoke('getDefaultAnkiFieldTemplates');
}
+function apiGetAnkiDeckNames() {
+ return _apiInvoke('getAnkiDeckNames');
+}
+
+function apiGetAnkiModelNames() {
+ return _apiInvoke('getAnkiModelNames');
+}
+
+function apiGetAnkiModelFieldNames(modelName) {
+ return _apiInvoke('getAnkiModelFieldNames', {modelName});
+}
+
+function apiGetDictionaryInfo() {
+ return _apiInvoke('getDictionaryInfo');
+}
+
+function apiGetDictionaryCounts(dictionaryNames, getTotal) {
+ return _apiInvoke('getDictionaryCounts', {dictionaryNames, getTotal});
+}
+
+function apiPurgeDatabase() {
+ return _apiInvoke('purgeDatabase');
+}
+
+function apiGetMedia(targets) {
+ return _apiInvoke('getMedia', {targets});
+}
+
+function apiLog(error, level, context) {
+ return _apiInvoke('log', {error, level, context});
+}
+
+function apiLogIndicatorClear() {
+ return _apiInvoke('logIndicatorClear');
+}
+
+function apiImportDictionaryArchive(archiveContent, details, onProgress) {
+ return _apiInvokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress);
+}
+
+function apiDeleteDictionary(dictionaryName, onProgress) {
+ return _apiInvokeWithProgress('deleteDictionary', {dictionaryName}, onProgress);
+}
+
+function apiModifySettings(targets, source) {
+ return _apiInvoke('modifySettings', {targets, source});
+}
+
+function _apiCreateActionPort(timeout=5000) {
+ return new Promise((resolve, reject) => {
+ let timer = null;
+ let portNameResolve;
+ let portNameReject;
+ const portNamePromise = new Promise((resolve2, reject2) => {
+ portNameResolve = resolve2;
+ portNameReject = reject2;
+ });
+
+ const onConnect = async (port) => {
+ try {
+ const portName = await portNamePromise;
+ if (port.name !== portName || timer === null) { return; }
+ } catch (e) {
+ return;
+ }
+
+ clearTimeout(timer);
+ timer = null;
+
+ chrome.runtime.onConnect.removeListener(onConnect);
+ resolve(port);
+ };
+
+ const onError = (e) => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ chrome.runtime.onConnect.removeListener(onConnect);
+ portNameReject(e);
+ reject(e);
+ };
+
+ timer = setTimeout(() => onError(new Error('Timeout')), timeout);
+
+ chrome.runtime.onConnect.addListener(onConnect);
+ _apiInvoke('createActionPort').then(portNameResolve, onError);
+ });
+}
+
+function _apiInvokeWithProgress(action, params, onProgress, timeout=5000) {
+ return new Promise((resolve, reject) => {
+ let timer = null;
+ let port = null;
+
+ if (typeof onProgress !== 'function') {
+ onProgress = () => {};
+ }
+
+ const onMessage = (message) => {
+ switch (message.type) {
+ case 'ack':
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ break;
+ case 'progress':
+ try {
+ onProgress(...message.data);
+ } catch (e) {
+ // NOP
+ }
+ break;
+ case 'complete':
+ cleanup();
+ resolve(message.data);
+ break;
+ case 'error':
+ cleanup();
+ reject(jsonToError(message.data));
+ break;
+ }
+ };
+
+ const onDisconnect = () => {
+ cleanup();
+ reject(new Error('Disconnected'));
+ };
+
+ const cleanup = () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ if (port !== null) {
+ port.onMessage.removeListener(onMessage);
+ port.onDisconnect.removeListener(onDisconnect);
+ port.disconnect();
+ port = null;
+ }
+ onProgress = null;
+ };
+
+ timer = setTimeout(() => {
+ cleanup();
+ reject(new Error('Timeout'));
+ }, timeout);
+
+ (async () => {
+ try {
+ port = await _apiCreateActionPort(timeout);
+ port.onMessage.addListener(onMessage);
+ port.onDisconnect.addListener(onDisconnect);
+ port.postMessage({action, params});
+ } catch (e) {
+ cleanup();
+ reject(e);
+ } finally {
+ action = null;
+ params = null;
+ }
+ })();
+ });
+}
+
function _apiInvoke(action, params={}) {
const data = {action, params};
return new Promise((resolve, reject) => {
@@ -143,3 +305,17 @@ function _apiInvoke(action, params={}) {
function _apiCheckLastError() {
// NOP
}
+
+let _apiForwardLogsToBackendEnabled = false;
+function apiForwardLogsToBackend() {
+ if (_apiForwardLogsToBackendEnabled) { return; }
+ _apiForwardLogsToBackendEnabled = true;
+
+ yomichan.on('log', async ({error, level, context}) => {
+ try {
+ await apiLog(errorToJson(error), level, context);
+ } catch (e) {
+ // NOP
+ }
+ });
+}
diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js
index 45b733fc..fdfb0b10 100644
--- a/ext/mixed/js/audio-system.js
+++ b/ext/mixed/js/audio-system.js
@@ -40,7 +40,7 @@ class TextToSpeechAudio {
}
}
- play() {
+ async play() {
try {
if (this._utterance === null) {
this._utterance = new SpeechSynthesisUtterance(this.text || '');
@@ -66,10 +66,10 @@ class TextToSpeechAudio {
}
class AudioSystem {
- constructor({getAudioUri}) {
- this._cache = new Map();
+ constructor({audioUriBuilder, useCache}) {
+ this._cache = useCache ? new Map() : null;
this._cacheSizeMaximum = 32;
- this._getAudioUri = getAudioUri;
+ this._audioUriBuilder = audioUriBuilder;
if (typeof speechSynthesis !== 'undefined') {
// speechSynthesis.getVoices() will not be populated unless some API call is made.
@@ -79,21 +79,35 @@ class AudioSystem {
async getDefinitionAudio(definition, sources, details) {
const key = `${definition.expression}:${definition.reading}`;
- const cacheValue = this._cache.get(definition);
- if (typeof cacheValue !== 'undefined') {
- const {audio, uri, source} = cacheValue;
- return {audio, uri, source};
+ const hasCache = (this._cache !== null && !details.disableCache);
+
+ if (hasCache) {
+ const cacheValue = this._cache.get(key);
+ if (typeof cacheValue !== 'undefined') {
+ const {audio, uri, source} = cacheValue;
+ const index = sources.indexOf(source);
+ if (index >= 0) {
+ return {audio, uri, index};
+ }
+ }
}
- for (const source of sources) {
+ for (let i = 0, ii = sources.length; i < ii; ++i) {
+ const source = sources[i];
const uri = await this._getAudioUri(definition, source, details);
if (uri === null) { continue; }
try {
- const audio = await this._createAudio(uri, details);
- this._cacheCheck();
- this._cache.set(key, {audio, uri, source});
- return {audio, uri, source};
+ const audio = (
+ details.binary ?
+ await this._createAudioBinary(uri) :
+ await this._createAudio(uri)
+ );
+ if (hasCache) {
+ this._cacheCheck();
+ this._cache.set(key, {audio, uri, source});
+ }
+ return {audio, uri, index: i};
} catch (e) {
// NOP
}
@@ -102,7 +116,7 @@ class AudioSystem {
throw new Error('Could not create audio');
}
- createTextToSpeechAudio({text, voiceUri}) {
+ createTextToSpeechAudio(text, voiceUri) {
const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri);
if (voice === null) {
throw new Error('Invalid text-to-speech voice');
@@ -114,27 +128,38 @@ class AudioSystem {
// NOP
}
- async _createAudio(uri, details) {
+ _getAudioUri(definition, source, details) {
+ return (
+ this._audioUriBuilder !== null ?
+ this._audioUriBuilder.getUri(definition, source, details) :
+ null
+ );
+ }
+
+ async _createAudio(uri) {
const ttsParameters = this._getTextToSpeechParameters(uri);
if (ttsParameters !== null) {
- if (typeof details === 'object' && details !== null) {
- if (details.tts === false) {
- throw new Error('Text-to-speech not permitted');
- }
- }
- return this.createTextToSpeechAudio(ttsParameters);
+ const {text, voiceUri} = ttsParameters;
+ return this.createTextToSpeechAudio(text, voiceUri);
}
return await this._createAudioFromUrl(uri);
}
+ async _createAudioBinary(uri) {
+ const ttsParameters = this._getTextToSpeechParameters(uri);
+ if (ttsParameters !== null) {
+ throw new Error('Cannot create audio from text-to-speech');
+ }
+
+ return await this._createAudioBinaryFromUrl(uri);
+ }
+
_createAudioFromUrl(url) {
return new Promise((resolve, reject) => {
const audio = new Audio(url);
audio.addEventListener('loadeddata', () => {
- const duration = audio.duration;
- if (duration === 5.694694 || duration === 5.720718) {
- // Hardcoded values for invalid audio
+ if (!this._isAudioValid(audio)) {
reject(new Error('Could not retrieve audio'));
} else {
resolve(audio);
@@ -144,6 +169,42 @@ class AudioSystem {
});
}
+ _createAudioBinaryFromUrl(url) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.responseType = 'arraybuffer';
+ xhr.addEventListener('load', async () => {
+ const arrayBuffer = xhr.response;
+ if (!await this._isAudioBinaryValid(arrayBuffer)) {
+ reject(new Error('Could not retrieve audio'));
+ } else {
+ resolve(arrayBuffer);
+ }
+ });
+ xhr.addEventListener('error', () => reject(new Error('Failed to connect')));
+ xhr.open('GET', url);
+ xhr.send();
+ });
+ }
+
+ _isAudioValid(audio) {
+ const duration = audio.duration;
+ return (
+ duration !== 5.694694 && // jpod101 invalid audio (Chrome)
+ duration !== 5.720718 // jpod101 invalid audio (Firefox)
+ );
+ }
+
+ async _isAudioBinaryValid(arrayBuffer) {
+ const digest = await AudioSystem.arrayBufferDigest(arrayBuffer);
+ switch (digest) {
+ case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // jpod101 invalid audio
+ return false;
+ default:
+ return true;
+ }
+ }
+
_getTextToSpeechVoiceFromVoiceUri(voiceUri) {
try {
for (const voice of speechSynthesis.getVoices()) {
@@ -181,4 +242,13 @@ class AudioSystem {
this._cache.delete(key);
}
}
+
+ static async arrayBufferDigest(arrayBuffer) {
+ const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer)));
+ let digest = '';
+ for (const byte of hash) {
+ digest += byte.toString(16).padStart(2, '0');
+ }
+ return digest;
+ }
}
diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js
index 2d11c11a..589425f2 100644
--- a/ext/mixed/js/core.js
+++ b/ext/mixed/js/core.js
@@ -52,15 +52,28 @@ if (EXTENSION_IS_BROWSER_EDGE) {
*/
function errorToJson(error) {
+ try {
+ if (isObject(error)) {
+ return {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ data: error.data
+ };
+ }
+ } catch (e) {
+ // NOP
+ }
return {
- name: error.name,
- message: error.message,
- stack: error.stack,
- data: error.data
+ value: error,
+ hasValue: true
};
}
function jsonToError(jsonError) {
+ if (jsonError.hasValue) {
+ return jsonError.value;
+ }
const error = new Error(jsonError.message);
error.name = jsonError.name;
error.stack = jsonError.stack;
@@ -68,28 +81,6 @@ function jsonToError(jsonError) {
return error;
}
-function logError(error, alert) {
- const manifest = chrome.runtime.getManifest();
- let errorMessage = `${manifest.name} v${manifest.version} has encountered an error.\n`;
- errorMessage += `Originating URL: ${window.location.href}\n`;
-
- const errorString = `${error.toString ? error.toString() : error}`;
- const stack = `${error.stack}`.trimRight();
- if (!stack.startsWith(errorString)) { errorMessage += `${errorString}\n`; }
- errorMessage += stack;
-
- const data = error.data;
- if (typeof data !== 'undefined') { errorMessage += `\nData: ${JSON.stringify(data, null, 4)}`; }
-
- errorMessage += '\n\nIssues can be reported at https://github.com/FooSoft/yomichan/issues';
-
- console.error(errorMessage);
-
- if (alert) {
- window.alert(`${errorString}\n\nCheck the developer console for more details.`);
- }
-}
-
/*
* Common helpers
@@ -103,6 +94,11 @@ function hasOwn(object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
}
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
+function escapeRegExp(string) {
+ return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
+}
+
// toIterable is required on Edge for cross-window origin objects.
function toIterable(value) {
if (typeof Symbol !== 'undefined' && typeof value[Symbol.iterator] !== 'undefined') {
@@ -155,6 +151,12 @@ function getSetIntersection(set1, set2) {
return result;
}
+function getSetDifference(set1, set2) {
+ return new Set(
+ [...set1].filter((value) => !set2.has(value))
+ );
+}
+
/*
* Async utilities
@@ -316,6 +318,15 @@ const yomichan = (() => {
this.trigger('orphaned', {error});
}
+ isExtensionUrl(url) {
+ try {
+ const urlBase = chrome.runtime.getURL('/');
+ return url.substring(0, urlBase.length) === urlBase;
+ } catch (e) {
+ return false;
+ }
+ }
+
getTemporaryListenerResult(eventHandler, userCallback, timeout=null) {
if (!(
typeof eventHandler.addListener === 'function' &&
@@ -352,8 +363,77 @@ const yomichan = (() => {
});
}
+ logWarning(error) {
+ this.log(error, 'warn');
+ }
+
+ logError(error) {
+ this.log(error, 'error');
+ }
+
+ log(error, level, context=null) {
+ if (!isObject(context)) {
+ context = this._getLogContext();
+ }
+
+ let errorString;
+ try {
+ errorString = error.toString();
+ if (/^\[object \w+\]$/.test(errorString)) {
+ errorString = JSON.stringify(error);
+ }
+ } catch (e) {
+ errorString = `${error}`;
+ }
+
+ let errorStack;
+ try {
+ errorStack = (typeof error.stack === 'string' ? error.stack.trimRight() : '');
+ } catch (e) {
+ errorStack = '';
+ }
+
+ let errorData;
+ try {
+ errorData = error.data;
+ } catch (e) {
+ // NOP
+ }
+
+ if (errorStack.startsWith(errorString)) {
+ errorString = errorStack;
+ } else if (errorStack.length > 0) {
+ errorString += `\n${errorStack}`;
+ }
+
+ const manifest = chrome.runtime.getManifest();
+ let message = `${manifest.name} v${manifest.version} has encountered a problem.`;
+ message += `\nOriginating URL: ${context.url}\n`;
+ message += errorString;
+ if (typeof errorData !== 'undefined') {
+ message += `\nData: ${JSON.stringify(errorData, null, 4)}`;
+ }
+ message += '\n\nIssues can be reported at https://github.com/FooSoft/yomichan/issues';
+
+ switch (level) {
+ case 'info': console.info(message); break;
+ case 'debug': console.debug(message); break;
+ case 'warn': console.warn(message); break;
+ case 'error': console.error(message); break;
+ default: console.log(message); break;
+ }
+
+ this.trigger('log', {error, level, context});
+ }
+
// Private
+ _getLogContext() {
+ return {
+ url: window.location.href
+ };
+ }
+
_onMessage({action, params}, sender, callback) {
const handler = this._messageHandlers.get(action);
if (typeof handler !== 'function') { return false; }
diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js
index 0f991362..a2b2b139 100644
--- a/ext/mixed/js/display-generator.js
+++ b/ext/mixed/js/display-generator.js
@@ -22,7 +22,8 @@
*/
class DisplayGenerator {
- constructor() {
+ constructor({mediaLoader}) {
+ this._mediaLoader = mediaLoader;
this._templateHandler = null;
this._termPitchAccentStaticTemplateIsSetup = false;
}
@@ -176,16 +177,30 @@ class DisplayGenerator {
const onlyListContainer = node.querySelector('.term-definition-disambiguation-list');
const glossaryContainer = node.querySelector('.term-glossary-list');
- node.dataset.dictionary = details.dictionary;
+ const dictionary = details.dictionary;
+ node.dataset.dictionary = dictionary;
this._appendMultiple(tagListContainer, this._createTag.bind(this), details.definitionTags);
this._appendMultiple(onlyListContainer, this._createTermDisambiguation.bind(this), details.only);
- this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary);
+ this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary, dictionary);
return node;
}
- _createTermGlossaryItem(glossary) {
+ _createTermGlossaryItem(glossary, dictionary) {
+ if (typeof glossary === 'string') {
+ return this._createTermGlossaryItemText(glossary);
+ } else if (typeof glossary === 'object' && glossary !== null) {
+ switch (glossary.type) {
+ case 'image':
+ return this._createTermGlossaryItemImage(glossary, dictionary);
+ }
+ }
+
+ return null;
+ }
+
+ _createTermGlossaryItemText(glossary) {
const node = this._templateHandler.instantiate('term-glossary-item');
const container = node.querySelector('.term-glossary');
if (container !== null) {
@@ -194,6 +209,68 @@ class DisplayGenerator {
return node;
}
+ _createTermGlossaryItemImage(data, dictionary) {
+ const {path, width, height, preferredWidth, preferredHeight, title, description, pixelated} = data;
+
+ const usedWidth = (
+ typeof preferredWidth === 'number' ?
+ preferredWidth :
+ width
+ );
+ const aspectRatio = (
+ typeof preferredWidth === 'number' &&
+ typeof preferredHeight === 'number' ?
+ preferredWidth / preferredHeight :
+ width / height
+ );
+
+ const node = this._templateHandler.instantiate('term-glossary-item-image');
+ node.dataset.path = path;
+ node.dataset.dictionary = dictionary;
+ node.dataset.imageLoadState = 'not-loaded';
+
+ const imageContainer = node.querySelector('.term-glossary-image-container');
+ imageContainer.style.width = `${usedWidth}em`;
+ if (typeof title === 'string') {
+ imageContainer.title = title;
+ }
+
+ const aspectRatioSizer = node.querySelector('.term-glossary-image-aspect-ratio-sizer');
+ aspectRatioSizer.style.paddingTop = `${aspectRatio * 100.0}%`;
+
+ const image = node.querySelector('img.term-glossary-image');
+ const imageLink = node.querySelector('.term-glossary-image-link');
+ image.dataset.pixelated = `${pixelated === true}`;
+
+ if (this._mediaLoader !== null) {
+ this._mediaLoader.loadMedia(
+ path,
+ dictionary,
+ (url) => this._setImageData(node, image, imageLink, url, false),
+ () => this._setImageData(node, image, imageLink, null, true)
+ );
+ }
+
+ if (typeof description === 'string') {
+ const container = node.querySelector('.term-glossary-image-description');
+ this._appendMultilineText(container, description);
+ }
+
+ return node;
+ }
+
+ _setImageData(container, image, imageLink, url, unloaded) {
+ if (url !== null) {
+ image.src = url;
+ imageLink.href = url;
+ container.dataset.imageLoadState = 'loaded';
+ } else {
+ image.removeAttribute('src');
+ imageLink.removeAttribute('href');
+ container.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error';
+ }
+ }
+
_createTermDisambiguation(disambiguation) {
const node = this._templateHandler.instantiate('term-definition-disambiguation');
node.dataset.term = disambiguation;
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index 63687dc2..2e59b4ff 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -20,6 +20,7 @@
* DOM
* DisplayContext
* DisplayGenerator
+ * MediaLoader
* WindowScroll
* apiAudioGetUri
* apiBroadcastTab
@@ -45,7 +46,14 @@ class Display {
this.index = 0;
this.audioPlaying = null;
this.audioFallback = null;
- this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)});
+ this.audioSystem = new AudioSystem({
+ audioUriBuilder: {
+ getUri: async (definition, source, details) => {
+ return await apiAudioGetUri(definition, source, details);
+ }
+ },
+ useCache: true
+ });
this.styleNode = null;
this.eventListeners = new EventListenerCollection();
@@ -55,12 +63,13 @@ class Display {
this.clickScanPrevent = false;
this.setContentToken = null;
- this.displayGenerator = new DisplayGenerator();
+ this.mediaLoader = new MediaLoader();
+ this.displayGenerator = new DisplayGenerator({mediaLoader: this.mediaLoader});
this.windowScroll = new WindowScroll();
this._onKeyDownHandlers = new Map([
['Escape', () => {
- this.onSearchClear();
+ this.onEscape();
return true;
}],
['PageUp', (e) => {
@@ -168,15 +177,13 @@ class Display {
async prepare() {
await yomichan.prepare();
await this.displayGenerator.prepare();
- await this.updateOptions();
- yomichan.on('optionsUpdated', () => this.updateOptions());
}
onError(_error) {
throw new Error('Override me');
}
- onSearchClear() {
+ onEscape() {
throw new Error('Override me');
}
@@ -331,7 +338,7 @@ class Display {
}
onKeyDown(e) {
- const key = Display.getKeyFromEvent(e);
+ const key = DOM.getKeyFromEvent(e);
const handler = this._onKeyDownHandlers.get(key);
if (typeof handler === 'function') {
if (handler(e)) {
@@ -392,12 +399,6 @@ class Display {
updateTheme(themeName) {
document.documentElement.dataset.yomichanTheme = themeName;
-
- const stylesheets = document.querySelectorAll('link[data-yomichan-theme-name]');
- for (const stylesheet of stylesheets) {
- const match = (stylesheet.dataset.yomichanThemeName === themeName);
- stylesheet.rel = (match ? 'stylesheet' : 'stylesheet alternate');
- }
}
setCustomCss(css) {
@@ -472,6 +473,8 @@ class Display {
const token = {}; // Unique identifier token
this.setContentToken = token;
try {
+ this.mediaLoader.unloadAll();
+
switch (type) {
case 'terms':
await this.setContentTerms(details.definitions, details.context, token);
@@ -784,16 +787,14 @@ class Display {
const expression = expressionIndex === -1 ? definition : definition.expressions[expressionIndex];
- if (this.audioPlaying !== null) {
- this.audioPlaying.pause();
- this.audioPlaying = null;
- }
+ this._stopPlayingAudio();
- const sources = this.options.audio.sources;
- let audio, source, info;
+ let audio, info;
try {
- ({audio, source} = await this.audioSystem.getDefinitionAudio(expression, sources));
- info = `From source ${1 + sources.indexOf(source)}: ${source}`;
+ const {sources, textToSpeechVoice, customSourceUrl} = this.options.audio;
+ let index;
+ ({audio, index} = await this.audioSystem.getDefinitionAudio(expression, sources, {textToSpeechVoice, customSourceUrl}));
+ info = `From source ${1 + index}: ${sources[index]}`;
} catch (e) {
if (this.audioFallback === null) {
this.audioFallback = new Audio('/mixed/mp3/button.mp3');
@@ -802,7 +803,7 @@ class Display {
info = 'Could not find audio';
}
- const button = this.audioButtonFindImage(entryIndex);
+ const button = this.audioButtonFindImage(entryIndex, expressionIndex);
if (button !== null) {
let titleDefault = button.dataset.titleDefault;
if (!titleDefault) {
@@ -812,10 +813,19 @@ class Display {
button.title = `${titleDefault}\n${info}`;
}
+ this._stopPlayingAudio();
+
this.audioPlaying = audio;
audio.currentTime = 0;
audio.volume = this.options.audio.volume / 100.0;
- audio.play();
+ const playPromise = audio.play();
+ if (typeof playPromise !== 'undefined') {
+ try {
+ await playPromise;
+ } catch (e2) {
+ // NOP
+ }
+ }
} catch (e) {
this.onError(e);
} finally {
@@ -823,6 +833,13 @@ class Display {
}
}
+ _stopPlayingAudio() {
+ if (this.audioPlaying !== null) {
+ this.audioPlaying.pause();
+ this.audioPlaying = null;
+ }
+ }
+
noteUsesScreenshot(mode) {
const optionsAnki = this.options.anki;
const fields = (mode === 'kanji' ? optionsAnki.kanji : optionsAnki.terms).fields;
@@ -901,9 +918,16 @@ class Display {
viewerButton.dataset.noteId = noteId;
}
- audioButtonFindImage(index) {
+ audioButtonFindImage(index, expressionIndex) {
const entry = this.getEntry(index);
- return entry !== null ? entry.querySelector('.action-play-audio>img') : null;
+ if (entry === null) { return null; }
+
+ const container = (
+ expressionIndex >= 0 ?
+ entry.querySelector(`.term-expression:nth-of-type(${expressionIndex + 1})`) :
+ entry
+ );
+ return container !== null ? container.querySelector('.action-play-audio>img') : null;
}
async getDefinitionsAddable(definitions, modes) {
@@ -934,11 +958,6 @@ class Display {
return elementRect.top - documentRect.top;
}
- static getKeyFromEvent(event) {
- const key = event.key;
- return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : '');
- }
-
async _getNoteContext() {
const documentTitle = await this.getDocumentTitle();
return {
@@ -947,9 +966,4 @@ class Display {
}
};
}
-
- async _getAudioUri(definition, source) {
- const optionsContext = this.getOptionsContext();
- return await apiAudioGetUri(definition, source, optionsContext);
- }
}
diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js
index 03acbb80..0e8f4462 100644
--- a/ext/mixed/js/dom.js
+++ b/ext/mixed/js/dom.js
@@ -62,4 +62,28 @@ class DOM {
default: return false;
}
}
+
+ static getActiveModifiers(event) {
+ const modifiers = new Set();
+ if (event.altKey) { modifiers.add('alt'); }
+ if (event.ctrlKey) { modifiers.add('ctrl'); }
+ if (event.metaKey) { modifiers.add('meta'); }
+ if (event.shiftKey) { modifiers.add('shift'); }
+ return modifiers;
+ }
+
+ static getKeyFromEvent(event) {
+ const key = event.key;
+ return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : '');
+ }
+
+ static getFullscreenElement() {
+ return (
+ document.fullscreenElement ||
+ document.msFullscreenElement ||
+ document.mozFullScreenElement ||
+ document.webkitFullscreenElement ||
+ null
+ );
+ }
}
diff --git a/ext/mixed/js/dynamic-loader-sentinel.js b/ext/mixed/js/dynamic-loader-sentinel.js
new file mode 100644
index 00000000..f783bdb7
--- /dev/null
+++ b/ext/mixed/js/dynamic-loader-sentinel.js
@@ -0,0 +1,18 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+yomichan.trigger('dynamicLoaderSentinel', {script: document.currentScript});
diff --git a/ext/mixed/js/dynamic-loader.js b/ext/mixed/js/dynamic-loader.js
new file mode 100644
index 00000000..ce946109
--- /dev/null
+++ b/ext/mixed/js/dynamic-loader.js
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * apiInjectStylesheet
+ */
+
+const dynamicLoader = (() => {
+ const injectedStylesheets = new Map();
+
+ async function loadStyle(id, type, value, useWebExtensionApi=false) {
+ if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) {
+ // Permissions error will occur if trying to use the WebExtension API to inject into an extension page
+ useWebExtensionApi = false;
+ }
+
+ let styleNode = injectedStylesheets.get(id);
+ if (typeof styleNode !== 'undefined') {
+ if (styleNode === null) {
+ // Previously injected via WebExtension API
+ throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`);
+ }
+ } else {
+ styleNode = null;
+ }
+
+ if (useWebExtensionApi) {
+ // Inject via WebExtension API
+ if (styleNode !== null && styleNode.parentNode !== null) {
+ styleNode.parentNode.removeChild(styleNode);
+ }
+
+ injectedStylesheets.set(id, null);
+ await apiInjectStylesheet(type, value);
+ return null;
+ }
+
+ // Create node in document
+ const parentNode = document.head;
+ if (parentNode === null) {
+ throw new Error('No parent node');
+ }
+
+ // Create or reuse node
+ const isFile = (type === 'file');
+ const tagName = isFile ? 'link' : 'style';
+ if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) {
+ if (styleNode !== null && styleNode.parentNode !== null) {
+ styleNode.parentNode.removeChild(styleNode);
+ }
+ styleNode = document.createElement(tagName);
+ }
+
+ // Update node style
+ if (isFile) {
+ styleNode.rel = 'stylesheet';
+ styleNode.href = value;
+ } else {
+ styleNode.textContent = value;
+ }
+
+ // Update parent
+ if (styleNode.parentNode !== parentNode) {
+ parentNode.appendChild(styleNode);
+ }
+
+ // Add to map
+ injectedStylesheets.set(id, styleNode);
+ return styleNode;
+ }
+
+ function loadScripts(urls) {
+ return new Promise((resolve, reject) => {
+ const parent = document.body;
+ if (parent === null) {
+ reject(new Error('Missing body'));
+ return;
+ }
+
+ for (const url of urls) {
+ const node = parent.querySelector(`script[src='${escapeCSSAttribute(url)}']`);
+ if (node !== null) { continue; }
+
+ const script = document.createElement('script');
+ script.async = false;
+ script.src = url;
+ parent.appendChild(script);
+ }
+
+ loadScriptSentinel(parent, resolve, reject);
+ });
+ }
+
+ function loadScriptSentinel(parent, resolve, reject) {
+ const script = document.createElement('script');
+
+ const sentinelEventName = 'dynamicLoaderSentinel';
+ const sentinelEventCallback = (e) => {
+ if (e.script !== script) { return; }
+ yomichan.off(sentinelEventName, sentinelEventCallback);
+ parent.removeChild(script);
+ resolve();
+ };
+ yomichan.on(sentinelEventName, sentinelEventCallback);
+
+ try {
+ script.async = false;
+ script.src = '/mixed/js/dynamic-loader-sentinel.js';
+ parent.appendChild(script);
+ } catch (e) {
+ yomichan.off(sentinelEventName, sentinelEventCallback);
+ reject(e);
+ }
+ }
+
+ function escapeCSSAttribute(value) {
+ return value.replace(/['\\]/g, (character) => `\\${character}`);
+ }
+
+
+ return {
+ loadStyle,
+ loadScripts
+ };
+})();
diff --git a/ext/mixed/js/environment.js b/ext/mixed/js/environment.js
new file mode 100644
index 00000000..e5bc20a7
--- /dev/null
+++ b/ext/mixed/js/environment.js
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+
+class Environment {
+ constructor() {
+ this._cachedEnvironmentInfo = null;
+ }
+
+ async prepare() {
+ this._cachedEnvironmentInfo = await this._loadEnvironmentInfo();
+ }
+
+ getInfo() {
+ if (this._cachedEnvironmentInfo === null) { throw new Error('Not prepared'); }
+ return this._cachedEnvironmentInfo;
+ }
+
+ async _loadEnvironmentInfo() {
+ const browser = await this._getBrowser();
+ const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
+ const modifierInfo = this._getModifierInfo(browser, platform.os);
+ return {
+ browser,
+ platform: {
+ os: platform.os
+ },
+ modifiers: modifierInfo
+ };
+ }
+
+ async _getBrowser() {
+ if (EXTENSION_IS_BROWSER_EDGE) {
+ return 'edge';
+ }
+ if (typeof browser !== 'undefined') {
+ try {
+ const info = await browser.runtime.getBrowserInfo();
+ if (info.name === 'Fennec') {
+ return 'firefox-mobile';
+ }
+ } catch (e) {
+ // NOP
+ }
+ return 'firefox';
+ } else {
+ return 'chrome';
+ }
+ }
+
+ _getModifierInfo(browser, os) {
+ let osKeys;
+ let separator;
+ switch (os) {
+ case 'win':
+ separator = ' + ';
+ osKeys = [
+ ['alt', 'Alt'],
+ ['ctrl', 'Ctrl'],
+ ['shift', 'Shift'],
+ ['meta', 'Windows']
+ ];
+ break;
+ case 'mac':
+ separator = '';
+ osKeys = [
+ ['alt', '⌥'],
+ ['ctrl', '⌃'],
+ ['shift', '⇧'],
+ ['meta', '⌘']
+ ];
+ break;
+ case 'linux':
+ case 'openbsd':
+ case 'cros':
+ case 'android':
+ separator = ' + ';
+ osKeys = [
+ ['alt', 'Alt'],
+ ['ctrl', 'Ctrl'],
+ ['shift', 'Shift'],
+ ['meta', 'Super']
+ ];
+ break;
+ default:
+ throw new Error(`Invalid OS: ${os}`);
+ }
+
+ const isFirefox = (browser === 'firefox' || browser === 'firefox-mobile');
+ const keys = [];
+
+ for (const [value, name] of osKeys) {
+ // Firefox doesn't support event.metaKey on platforms other than macOS
+ if (value === 'meta' && isFirefox && os !== 'mac') { continue; }
+ keys.push({value, name});
+ }
+
+ return {keys, separator};
+ }
+}
diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js
index 79d69946..801dec84 100644
--- a/ext/mixed/js/japanese.js
+++ b/ext/mixed/js/japanese.js
@@ -16,6 +16,11 @@
*/
const jp = (() => {
+ const ITERATION_MARK_CODE_POINT = 0x3005;
+ const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063;
+ const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3;
+ const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
+
const HIRAGANA_RANGE = [0x3040, 0x309f];
const KATAKANA_RANGE = [0x30a0, 0x30ff];
const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE];
@@ -65,20 +70,65 @@ const jp = (() => {
const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ'));
+ const HALFWIDTH_KATAKANA_MAPPING = new Map([
+ ['ヲ', 'ヲヺ-'],
+ ['ァ', 'ァ--'],
+ ['ィ', 'ィ--'],
+ ['ゥ', 'ゥ--'],
+ ['ェ', 'ェ--'],
+ ['ォ', 'ォ--'],
+ ['ャ', 'ャ--'],
+ ['ュ', 'ュ--'],
+ ['ョ', 'ョ--'],
+ ['ッ', 'ッ--'],
+ ['ー', 'ー--'],
+ ['ア', 'ア--'],
+ ['イ', 'イ--'],
+ ['ウ', 'ウヴ-'],
+ ['エ', 'エ--'],
+ ['オ', 'オ--'],
+ ['カ', 'カガ-'],
+ ['キ', 'キギ-'],
+ ['ク', 'クグ-'],
+ ['ケ', 'ケゲ-'],
+ ['コ', 'コゴ-'],
+ ['サ', 'サザ-'],
+ ['シ', 'シジ-'],
+ ['ス', 'スズ-'],
+ ['セ', 'セゼ-'],
+ ['ソ', 'ソゾ-'],
+ ['タ', 'タダ-'],
+ ['チ', 'チヂ-'],
+ ['ツ', 'ツヅ-'],
+ ['テ', 'テデ-'],
+ ['ト', 'トド-'],
+ ['ナ', 'ナ--'],
+ ['ニ', 'ニ--'],
+ ['ヌ', 'ヌ--'],
+ ['ネ', 'ネ--'],
+ ['ノ', 'ノ--'],
+ ['ハ', 'ハバパ'],
+ ['ヒ', 'ヒビピ'],
+ ['フ', 'フブプ'],
+ ['ヘ', 'ヘベペ'],
+ ['ホ', 'ホボポ'],
+ ['マ', 'マ--'],
+ ['ミ', 'ミ--'],
+ ['ム', 'ム--'],
+ ['メ', 'メ--'],
+ ['モ', 'モ--'],
+ ['ヤ', 'ヤ--'],
+ ['ユ', 'ユ--'],
+ ['ヨ', 'ヨ--'],
+ ['ラ', 'ラ--'],
+ ['リ', 'リ--'],
+ ['ル', 'ル--'],
+ ['レ', 'レ--'],
+ ['ロ', 'ロ--'],
+ ['ワ', 'ワ--'],
+ ['ン', 'ン--']
+ ]);
- // Character code testing functions
-
- function isCodePointKanji(codePoint) {
- return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES);
- }
-
- function isCodePointKana(codePoint) {
- return isCodePointInRanges(codePoint, KANA_RANGES);
- }
-
- function isCodePointJapanese(codePoint) {
- return isCodePointInRanges(codePoint, JAPANESE_RANGES);
- }
function isCodePointInRanges(codePoint, ranges) {
for (const [min, max] of ranges) {
@@ -89,59 +139,410 @@ const jp = (() => {
return false;
}
+ function getWanakana() {
+ try {
+ if (typeof wanakana !== 'undefined') {
+ // eslint-disable-next-line no-undef
+ return wanakana;
+ }
+ } catch (e) {
+ // NOP
+ }
+ return null;
+ }
+
+
+ class JapaneseUtil {
+ constructor(wanakana=null) {
+ this._wanakana = wanakana;
+ }
+
+ // Character code testing functions
+
+ isCodePointKanji(codePoint) {
+ return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES);
+ }
+
+ isCodePointKana(codePoint) {
+ return isCodePointInRanges(codePoint, KANA_RANGES);
+ }
- // String testing functions
+ isCodePointJapanese(codePoint) {
+ return isCodePointInRanges(codePoint, JAPANESE_RANGES);
+ }
+
+ // String testing functions
- function isStringEntirelyKana(str) {
- if (str.length === 0) { return false; }
- for (const c of str) {
- if (!isCodePointKana(c.codePointAt(0))) {
- return false;
+ isStringEntirelyKana(str) {
+ if (str.length === 0) { return false; }
+ for (const c of str) {
+ if (!isCodePointInRanges(c.codePointAt(0), KANA_RANGES)) {
+ return false;
+ }
}
+ return true;
}
- return true;
- }
- function isStringPartiallyJapanese(str) {
- if (str.length === 0) { return false; }
- for (const c of str) {
- if (isCodePointJapanese(c.codePointAt(0))) {
- return true;
+ isStringPartiallyJapanese(str) {
+ if (str.length === 0) { return false; }
+ for (const c of str) {
+ if (isCodePointInRanges(c.codePointAt(0), JAPANESE_RANGES)) {
+ return true;
+ }
}
+ return false;
}
- return false;
- }
+ // Mora functions
- // Mora functions
+ isMoraPitchHigh(moraIndex, pitchAccentPosition) {
+ switch (pitchAccentPosition) {
+ case 0: return (moraIndex > 0);
+ case 1: return (moraIndex < 1);
+ default: return (moraIndex > 0 && moraIndex < pitchAccentPosition);
+ }
+ }
- function isMoraPitchHigh(moraIndex, pitchAccentPosition) {
- return pitchAccentPosition === 0 ? (moraIndex > 0) : (moraIndex < pitchAccentPosition);
- }
+ getKanaMorae(text) {
+ const morae = [];
+ let i;
+ for (const c of text) {
+ if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) {
+ morae[i - 1] += c;
+ } else {
+ morae.push(c);
+ }
+ }
+ return morae;
+ }
+
+ // Conversion functions
- function getKanaMorae(text) {
- const morae = [];
- let i;
- for (const c of text) {
- if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) {
- morae[i - 1] += c;
- } else {
- morae.push(c);
+ convertKatakanaToHiragana(text) {
+ const wanakana = this._getWanakana();
+ let result = '';
+ for (const c of text) {
+ if (wanakana.isKatakana(c)) {
+ result += wanakana.toHiragana(c);
+ } else {
+ result += c;
+ }
}
+
+ return result;
}
- return morae;
- }
+ convertHiraganaToKatakana(text) {
+ const wanakana = this._getWanakana();
+ let result = '';
+ for (const c of text) {
+ if (wanakana.isHiragana(c)) {
+ result += wanakana.toKatakana(c);
+ } else {
+ result += c;
+ }
+ }
+
+ return result;
+ }
+
+ convertToRomaji(text) {
+ const wanakana = this._getWanakana();
+ return wanakana.toRomaji(text);
+ }
+
+ convertReading(expression, reading, readingMode) {
+ switch (readingMode) {
+ case 'hiragana':
+ return this.convertKatakanaToHiragana(reading);
+ case 'katakana':
+ return this.convertHiraganaToKatakana(reading);
+ case 'romaji':
+ if (reading) {
+ return this.convertToRomaji(reading);
+ } else {
+ if (this.isStringEntirelyKana(expression)) {
+ return this.convertToRomaji(expression);
+ }
+ }
+ return reading;
+ case 'none':
+ return '';
+ default:
+ return reading;
+ }
+ }
+
+ convertNumericToFullWidth(text) {
+ let result = '';
+ for (const char of text) {
+ let c = char.codePointAt(0);
+ if (c >= 0x30 && c <= 0x39) { // ['0', '9']
+ c += 0xff10 - 0x30; // 0xff10 = '0' full width
+ result += String.fromCodePoint(c);
+ } else {
+ result += char;
+ }
+ }
+ return result;
+ }
+
+ convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
+ let result = '';
+
+ // This function is safe to use charCodeAt instead of codePointAt, since all
+ // the relevant characters are represented with a single UTF-16 character code.
+ for (let i = 0, ii = text.length; i < ii; ++i) {
+ const c = text[i];
+ const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c);
+ if (typeof mapping !== 'string') {
+ result += c;
+ continue;
+ }
+
+ let index = 0;
+ switch (text.charCodeAt(i + 1)) {
+ case 0xff9e: // dakuten
+ index = 1;
+ break;
+ case 0xff9f: // handakuten
+ index = 2;
+ break;
+ }
+
+ let c2 = mapping[index];
+ if (index > 0) {
+ if (c2 === '-') { // invalid
+ index = 0;
+ c2 = mapping[0];
+ } else {
+ ++i;
+ }
+ }
+
+ if (sourceMap !== null && index > 0) {
+ sourceMap.combine(result.length, 1);
+ }
+ result += c2;
+ }
+
+ return result;
+ }
+
+ convertAlphabeticToKana(text, sourceMap=null) {
+ let part = '';
+ let result = '';
+
+ for (const char of text) {
+ // Note: 0x61 is the character code for 'a'
+ let c = char.codePointAt(0);
+ if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
+ c += (0x61 - 0x41);
+ } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
+ // NOP; c += (0x61 - 0x61);
+ } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
+ c += (0x61 - 0xff21);
+ } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
+ c += (0x61 - 0xff41);
+ } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
+ c = 0x2d; // '-'
+ } else {
+ if (part.length > 0) {
+ result += this._convertAlphabeticPartToKana(part, sourceMap, result.length);
+ part = '';
+ }
+ result += char;
+ continue;
+ }
+ part += String.fromCodePoint(c);
+ }
+
+ if (part.length > 0) {
+ result += this._convertAlphabeticPartToKana(part, sourceMap, result.length);
+ }
+ return result;
+ }
+
+ // Furigana distribution
+
+ distributeFurigana(expression, reading) {
+ const fallback = [{furigana: reading, text: expression}];
+ if (!reading) {
+ return fallback;
+ }
+
+ let isAmbiguous = false;
+ const segmentize = (reading2, groups) => {
+ if (groups.length === 0 || isAmbiguous) {
+ return [];
+ }
+
+ const group = groups[0];
+ if (group.mode === 'kana') {
+ if (this.convertKatakanaToHiragana(reading2).startsWith(this.convertKatakanaToHiragana(group.text))) {
+ const readingLeft = reading2.substring(group.text.length);
+ const segs = segmentize(readingLeft, groups.splice(1));
+ if (segs) {
+ return [{text: group.text, furigana: ''}].concat(segs);
+ }
+ }
+ } else {
+ let foundSegments = null;
+ for (let i = reading2.length; i >= group.text.length; --i) {
+ const readingUsed = reading2.substring(0, i);
+ const readingLeft = reading2.substring(i);
+ const segs = segmentize(readingLeft, groups.slice(1));
+ if (segs) {
+ if (foundSegments !== null) {
+ // more than one way to segmentize the tail, mark as ambiguous
+ isAmbiguous = true;
+ return null;
+ }
+ foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs);
+ }
+ // there is only one way to segmentize the last non-kana group
+ if (groups.length === 1) {
+ break;
+ }
+ }
+ return foundSegments;
+ }
+ };
+
+ const groups = [];
+ let modePrev = null;
+ for (const c of expression) {
+ const codePoint = c.codePointAt(0);
+ const modeCurr = this.isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT ? 'kanji' : 'kana';
+ if (modeCurr === modePrev) {
+ groups[groups.length - 1].text += c;
+ } else {
+ groups.push({mode: modeCurr, text: c});
+ modePrev = modeCurr;
+ }
+ }
+
+ const segments = segmentize(reading, groups);
+ if (segments && !isAmbiguous) {
+ return segments;
+ }
+ return fallback;
+ }
+
+ distributeFuriganaInflected(expression, reading, source) {
+ const output = [];
+
+ let stemLength = 0;
+ const shortest = Math.min(source.length, expression.length);
+ const sourceHiragana = this.convertKatakanaToHiragana(source);
+ const expressionHiragana = this.convertKatakanaToHiragana(expression);
+ while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) {
+ ++stemLength;
+ }
+ const offset = source.length - stemLength;
+
+ const stemExpression = source.substring(0, source.length - offset);
+ const stemReading = reading.substring(
+ 0,
+ offset === 0 ? reading.length : reading.length - expression.length + stemLength
+ );
+ for (const segment of this.distributeFurigana(stemExpression, stemReading)) {
+ output.push(segment);
+ }
+
+ if (stemLength !== source.length) {
+ output.push({text: source.substring(stemLength), furigana: ''});
+ }
+
+ return output;
+ }
+
+ // Miscellaneous
+
+ collapseEmphaticSequences(text, fullCollapse, sourceMap=null) {
+ let result = '';
+ let collapseCodePoint = -1;
+ const hasSourceMap = (sourceMap !== null);
+ for (const char of text) {
+ const c = char.codePointAt(0);
+ if (
+ c === HIRAGANA_SMALL_TSU_CODE_POINT ||
+ c === KATAKANA_SMALL_TSU_CODE_POINT ||
+ c === KANA_PROLONGED_SOUND_MARK_CODE_POINT
+ ) {
+ if (collapseCodePoint !== c) {
+ collapseCodePoint = c;
+ if (!fullCollapse) {
+ result += char;
+ continue;
+ }
+ }
+ } else {
+ collapseCodePoint = -1;
+ result += char;
+ continue;
+ }
+
+ if (hasSourceMap) {
+ sourceMap.combine(Math.max(0, result.length - 1), 1);
+ }
+ }
+ return result;
+ }
+
+ // Private
+
+ _getWanakana() {
+ const wanakana = this._wanakana;
+ if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); }
+ return wanakana;
+ }
+
+ _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) {
+ const wanakana = this._getWanakana();
+ const result = wanakana.toHiragana(text);
+
+ // Generate source mapping
+ if (sourceMap !== null) {
+ let i = 0;
+ let resultPos = 0;
+ const ii = text.length;
+ while (i < ii) {
+ // Find smallest matching substring
+ let iNext = i + 1;
+ let resultPosNext = result.length;
+ while (iNext < ii) {
+ const t = wanakana.toHiragana(text.substring(0, iNext));
+ if (t === result.substring(0, t.length)) {
+ resultPosNext = t.length;
+ break;
+ }
+ ++iNext;
+ }
+
+ // Merge characters
+ const removals = iNext - i - 1;
+ if (removals > 0) {
+ sourceMap.combine(sourceMapStart, removals);
+ }
+ ++sourceMapStart;
+
+ // Empty elements
+ const additions = resultPosNext - resultPos - 1;
+ for (let j = 0; j < additions; ++j) {
+ sourceMap.insert(sourceMapStart, 0);
+ ++sourceMapStart;
+ }
+
+ i = iNext;
+ resultPos = resultPosNext;
+ }
+ }
+
+ return result;
+ }
+ }
- // Exports
- return {
- isCodePointKanji,
- isCodePointKana,
- isCodePointJapanese,
- isStringEntirelyKana,
- isStringPartiallyJapanese,
- isMoraPitchHigh,
- getKanaMorae
- };
+ return new JapaneseUtil(getWanakana());
})();
diff --git a/ext/mixed/js/media-loader.js b/ext/mixed/js/media-loader.js
new file mode 100644
index 00000000..64ccd715
--- /dev/null
+++ b/ext/mixed/js/media-loader.js
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * apiGetMedia
+ */
+
+class MediaLoader {
+ constructor() {
+ this._token = {};
+ this._mediaCache = new Map();
+ this._loadMediaData = [];
+ }
+
+ async loadMedia(path, dictionaryName, onLoad, onUnload) {
+ const token = this.token;
+ const data = {onUnload, loaded: false};
+
+ this._loadMediaData.push(data);
+
+ const media = await this.getMedia(path, dictionaryName);
+ if (token !== this.token) { return; }
+
+ onLoad(media.url);
+ data.loaded = true;
+ }
+
+ unloadAll() {
+ for (const {onUnload, loaded} of this._loadMediaData) {
+ if (typeof onUnload === 'function') {
+ onUnload(loaded);
+ }
+ }
+ this._loadMediaData = [];
+
+ for (const map of this._mediaCache.values()) {
+ for (const {url} of map.values()) {
+ if (url !== null) {
+ URL.revokeObjectURL(url);
+ }
+ }
+ }
+ this._mediaCache.clear();
+
+ this._token = {};
+ }
+
+ async getMedia(path, dictionaryName) {
+ let cachedData;
+ let dictionaryCache = this._mediaCache.get(dictionaryName);
+ if (typeof dictionaryCache !== 'undefined') {
+ cachedData = dictionaryCache.get(path);
+ } else {
+ dictionaryCache = new Map();
+ this._mediaCache.set(dictionaryName, dictionaryCache);
+ }
+
+ if (typeof cachedData === 'undefined') {
+ cachedData = {
+ promise: null,
+ data: null,
+ url: null
+ };
+ dictionaryCache.set(path, cachedData);
+ cachedData.promise = this._getMediaData(path, dictionaryName, cachedData);
+ }
+
+ return cachedData.promise;
+ }
+
+ async _getMediaData(path, dictionaryName, cachedData) {
+ const token = this._token;
+ const data = (await apiGetMedia([{path, dictionaryName}]))[0];
+ if (token === this._token && data !== null) {
+ const contentArrayBuffer = this._base64ToArrayBuffer(data.content);
+ const blob = new Blob([contentArrayBuffer], {type: data.mediaType});
+ const url = URL.createObjectURL(blob);
+ cachedData.data = data;
+ cachedData.url = url;
+ }
+ return cachedData;
+ }
+
+ _base64ToArrayBuffer(content) {
+ const binaryContent = window.atob(content);
+ const length = binaryContent.length;
+ const array = new Uint8Array(length);
+ for (let i = 0; i < length; ++i) {
+ array[i] = binaryContent.charCodeAt(i);
+ }
+ return array.buffer;
+ }
+}
diff --git a/ext/mixed/js/object-property-accessor.js b/ext/mixed/js/object-property-accessor.js
index 349037b3..07b8df61 100644
--- a/ext/mixed/js/object-property-accessor.js
+++ b/ext/mixed/js/object-property-accessor.js
@@ -16,15 +16,27 @@
*/
/**
- * Class used to get and set generic properties of an object by using path strings.
+ * Class used to get and mutate generic properties of an object by using path strings.
*/
class ObjectPropertyAccessor {
- constructor(target, setter=null) {
+ /**
+ * Create a new accessor for a specific object.
+ * @param target The object which the getter and mutation methods are applied to.
+ * @returns A new ObjectPropertyAccessor instance.
+ */
+ constructor(target) {
this._target = target;
- this._setter = (typeof setter === 'function' ? setter : null);
}
- getProperty(pathArray, pathLength) {
+ /**
+ * Gets the value at the specified path.
+ * @param pathArray The path to the property on the target object.
+ * @param pathLength How many parts of the pathArray to use.
+ * This parameter is optional and defaults to the length of pathArray.
+ * @returns The value found at the path.
+ * @throws An error is thrown if pathArray is not valid for the target object.
+ */
+ get(pathArray, pathLength) {
let target = this._target;
const ii = typeof pathLength === 'number' ? Math.min(pathArray.length, pathLength) : pathArray.length;
for (let i = 0; i < ii; ++i) {
@@ -37,24 +49,89 @@ class ObjectPropertyAccessor {
return target;
}
- setProperty(pathArray, value) {
- if (pathArray.length === 0) {
- throw new Error('Invalid path');
+ /**
+ * Sets the value at the specified path.
+ * @param pathArray The path to the property on the target object.
+ * @param value The value to assign to the property.
+ * @throws An error is thrown if pathArray is not valid for the target object.
+ */
+ set(pathArray, value) {
+ const ii = pathArray.length - 1;
+ if (ii < 0) { throw new Error('Invalid path'); }
+
+ const target = this.get(pathArray, ii);
+ const key = pathArray[ii];
+ if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) {
+ throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`);
}
- const target = this.getProperty(pathArray, pathArray.length - 1);
- const key = pathArray[pathArray.length - 1];
+ target[key] = value;
+ }
+
+ /**
+ * Deletes the property of the target object at the specified path.
+ * @param pathArray The path to the property on the target object.
+ * @throws An error is thrown if pathArray is not valid for the target object.
+ */
+ delete(pathArray) {
+ const ii = pathArray.length - 1;
+ if (ii < 0) { throw new Error('Invalid path'); }
+
+ const target = this.get(pathArray, ii);
+ const key = pathArray[ii];
if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) {
throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`);
}
- if (this._setter !== null) {
- this._setter(target, key, value, pathArray);
- } else {
- target[key] = value;
+ if (Array.isArray(target)) {
+ throw new Error('Invalid type');
+ }
+
+ delete target[key];
+ }
+
+ /**
+ * Swaps two properties of an object or array.
+ * @param pathArray1 The path to the first property on the target object.
+ * @param pathArray2 The path to the second property on the target object.
+ * @throws An error is thrown if pathArray1 or pathArray2 is not valid for the target object,
+ * or if the swap cannot be performed.
+ */
+ swap(pathArray1, pathArray2) {
+ const ii1 = pathArray1.length - 1;
+ if (ii1 < 0) { throw new Error('Invalid path 1'); }
+ const target1 = this.get(pathArray1, ii1);
+ const key1 = pathArray1[ii1];
+ if (!ObjectPropertyAccessor.isValidPropertyType(target1, key1)) { throw new Error(`Invalid path 1: ${ObjectPropertyAccessor.getPathString(pathArray1)}`); }
+
+ const ii2 = pathArray2.length - 1;
+ if (ii2 < 0) { throw new Error('Invalid path 2'); }
+ const target2 = this.get(pathArray2, ii2);
+ const key2 = pathArray2[ii2];
+ if (!ObjectPropertyAccessor.isValidPropertyType(target2, key2)) { throw new Error(`Invalid path 2: ${ObjectPropertyAccessor.getPathString(pathArray2)}`); }
+
+ const value1 = target1[key1];
+ const value2 = target2[key2];
+
+ target1[key1] = value2;
+ try {
+ target2[key2] = value1;
+ } catch (e) {
+ // Revert
+ try {
+ target1[key1] = value1;
+ } catch (e2) {
+ // NOP
+ }
+ throw e;
}
}
+ /**
+ * Converts a path string to a path array.
+ * @param pathArray The path array to convert.
+ * @returns A string representation of pathArray.
+ */
static getPathString(pathArray) {
const regexShort = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
let pathString = '';
@@ -86,6 +163,12 @@ class ObjectPropertyAccessor {
return pathString;
}
+ /**
+ * Converts a path array to a path string. For the most part, the format of this string
+ * matches Javascript's notation for property access.
+ * @param pathString The path string to convert.
+ * @returns An array representation of pathString.
+ */
static getPathArray(pathString) {
const pathArray = [];
let state = 'empty';
@@ -201,6 +284,14 @@ class ObjectPropertyAccessor {
return pathArray;
}
+ /**
+ * Checks whether an object or array has the specified property.
+ * @param object The object to test.
+ * @param property The property to check for existence.
+ * This value should be a string if the object is a non-array object.
+ * For arrays, it should be an integer.
+ * @returns true if the property exists, otherwise false.
+ */
static hasProperty(object, property) {
switch (typeof property) {
case 'string':
@@ -222,6 +313,14 @@ class ObjectPropertyAccessor {
}
}
+ /**
+ * Checks whether a property is valid for the given object
+ * @param object The object to test.
+ * @param property The property to check for existence.
+ * @returns true if the property is correct for the given object type, otherwise false.
+ * For arrays, this means that the property should be a positive integer.
+ * For non-array objects, the property should be a string.
+ */
static isValidPropertyType(object, property) {
switch (typeof property) {
case 'string':
diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js
index 0cd12cd7..b8688b08 100644
--- a/ext/mixed/js/text-scanner.js
+++ b/ext/mixed/js/text-scanner.js
@@ -21,47 +21,172 @@
* docRangeFromPoint
*/
-class TextScanner {
- constructor(node, ignoreElements, ignorePoints) {
- this.node = node;
- this.ignoreElements = ignoreElements;
- this.ignorePoints = ignorePoints;
-
- this.ignoreNodes = null;
-
- this.scanTimerPromise = null;
- this.causeCurrent = null;
- this.textSourceCurrent = null;
- this.pendingLookup = false;
- this.options = null;
-
- this.enabled = false;
- this.eventListeners = new EventListenerCollection();
-
- this.primaryTouchIdentifier = null;
- this.preventNextContextMenu = false;
- this.preventNextMouseDown = false;
- this.preventNextClick = false;
- this.preventScroll = false;
+class TextScanner extends EventDispatcher {
+ constructor({node, ignoreElements, ignorePoint, search}) {
+ super();
+ this._node = node;
+ this._ignoreElements = ignoreElements;
+ this._ignorePoint = ignorePoint;
+ this._search = search;
+
+ this._ignoreNodes = null;
+
+ this._causeCurrent = null;
+ this._scanTimerPromise = null;
+ this._textSourceCurrent = null;
+ this._textSourceCurrentSelected = false;
+ this._pendingLookup = false;
+ this._options = null;
+
+ this._enabled = false;
+ this._eventListeners = new EventListenerCollection();
+
+ this._primaryTouchIdentifier = null;
+ this._preventNextContextMenu = false;
+ this._preventNextMouseDown = false;
+ this._preventNextClick = false;
+ this._preventScroll = false;
+
+ this._canClearSelection = true;
}
- onMouseOver(e) {
- if (this.ignoreElements().includes(e.target)) {
- this.scanTimerClear();
+ get canClearSelection() {
+ return this._canClearSelection;
+ }
+
+ set canClearSelection(value) {
+ this._canClearSelection = value;
+ }
+
+ get ignoreNodes() {
+ return this._ignoreNodes;
+ }
+
+ set ignoreNodes(value) {
+ this._ignoreNodes = value;
+ }
+
+ get causeCurrent() {
+ return this._causeCurrent;
+ }
+
+ setEnabled(enabled) {
+ this._eventListeners.removeAllEventListeners();
+ this._enabled = enabled;
+ if (this._enabled) {
+ this._hookEvents();
+ } else {
+ this.clearSelection(true);
+ }
+ }
+
+ setOptions(options) {
+ this._options = options;
+ }
+
+ async searchAt(x, y, cause) {
+ try {
+ this._scanTimerClear();
+
+ if (this._pendingLookup) {
+ return;
+ }
+
+ if (typeof this._ignorePoint === 'function' && await this._ignorePoint(x, y)) {
+ return;
+ }
+
+ const textSource = docRangeFromPoint(x, y, this._options.scanning.deepDomScan);
+ try {
+ if (this._textSourceCurrent !== null && this._textSourceCurrent.equals(textSource)) {
+ return;
+ }
+
+ this._pendingLookup = true;
+ const result = await this._search(textSource, cause);
+ if (result !== null) {
+ this._causeCurrent = cause;
+ this.setCurrentTextSource(textSource);
+ }
+ this._pendingLookup = false;
+ } finally {
+ if (textSource !== null) {
+ textSource.cleanup();
+ }
+ }
+ } catch (e) {
+ yomichan.logError(e);
+ }
+ }
+
+ getTextSourceContent(textSource, length) {
+ const clonedTextSource = textSource.clone();
+
+ clonedTextSource.setEndOffset(length);
+
+ if (this._ignoreNodes !== null && clonedTextSource.range) {
+ length = clonedTextSource.text().length;
+ while (clonedTextSource.range && length > 0) {
+ const nodes = TextSourceRange.getNodesInRange(clonedTextSource.range);
+ if (!TextSourceRange.anyNodeMatchesSelector(nodes, this._ignoreNodes)) {
+ break;
+ }
+ --length;
+ clonedTextSource.setEndOffset(length);
+ }
+ }
+
+ return clonedTextSource.text();
+ }
+
+ clearSelection(passive) {
+ if (!this._canClearSelection) { return; }
+ if (this._textSourceCurrent !== null) {
+ if (this._textSourceCurrentSelected) {
+ this._textSourceCurrent.deselect();
+ }
+ this._textSourceCurrent = null;
+ this._textSourceCurrentSelected = false;
+ }
+ this.trigger('clearSelection', {passive});
+ }
+
+ getCurrentTextSource() {
+ return this._textSourceCurrent;
+ }
+
+ setCurrentTextSource(textSource) {
+ this._textSourceCurrent = textSource;
+ if (this._options.scanning.selectText) {
+ this._textSourceCurrent.select();
+ this._textSourceCurrentSelected = true;
+ } else {
+ this._textSourceCurrentSelected = false;
+ }
+ }
+
+ // Private
+
+ _onMouseOver(e) {
+ if (this._ignoreElements().includes(e.target)) {
+ this._scanTimerClear();
}
}
- onMouseMove(e) {
- this.scanTimerClear();
+ _onMouseMove(e) {
+ this._scanTimerClear();
- if (this.pendingLookup || DOM.isMouseButtonDown(e, 'primary')) {
+ if (this._pendingLookup || DOM.isMouseButtonDown(e, 'primary')) {
return;
}
- const scanningOptions = this.options.scanning;
+ const modifiers = DOM.getActiveModifiers(e);
+ this.trigger('activeModifiersChanged', {modifiers});
+
+ const scanningOptions = this._options.scanning;
const scanningModifier = scanningOptions.modifier;
if (!(
- TextScanner.isScanningModifierPressed(scanningModifier, e) ||
+ this._isScanningModifierPressed(scanningModifier, e) ||
(scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary'))
)) {
return;
@@ -69,7 +194,7 @@ class TextScanner {
const search = async () => {
if (scanningModifier === 'none') {
- if (!await this.scanTimerWait()) {
+ if (!await this._scanTimerWait()) {
// Aborted
return;
}
@@ -81,112 +206,110 @@ class TextScanner {
search();
}
- onMouseDown(e) {
- if (this.preventNextMouseDown) {
- this.preventNextMouseDown = false;
- this.preventNextClick = true;
+ _onMouseDown(e) {
+ if (this._preventNextMouseDown) {
+ this._preventNextMouseDown = false;
+ this._preventNextClick = true;
e.preventDefault();
e.stopPropagation();
return false;
}
if (DOM.isMouseButtonDown(e, 'primary')) {
- this.scanTimerClear();
- this.onSearchClear(true);
+ this._scanTimerClear();
+ this.clearSelection(false);
}
}
- onMouseOut() {
- this.scanTimerClear();
+ _onMouseOut() {
+ this._scanTimerClear();
}
- onClick(e) {
- if (this.preventNextClick) {
- this.preventNextClick = false;
+ _onClick(e) {
+ if (this._preventNextClick) {
+ this._preventNextClick = false;
e.preventDefault();
e.stopPropagation();
return false;
}
}
- onAuxClick() {
- this.preventNextContextMenu = false;
+ _onAuxClick() {
+ this._preventNextContextMenu = false;
}
- onContextMenu(e) {
- if (this.preventNextContextMenu) {
- this.preventNextContextMenu = false;
+ _onContextMenu(e) {
+ if (this._preventNextContextMenu) {
+ this._preventNextContextMenu = false;
e.preventDefault();
e.stopPropagation();
return false;
}
}
- onTouchStart(e) {
- if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) {
+ _onTouchStart(e) {
+ if (this._primaryTouchIdentifier !== null || e.changedTouches.length === 0) {
return;
}
- this.preventScroll = false;
- this.preventNextContextMenu = false;
- this.preventNextMouseDown = false;
- this.preventNextClick = false;
+ this._preventScroll = false;
+ this._preventNextContextMenu = false;
+ this._preventNextMouseDown = false;
+ this._preventNextClick = false;
const primaryTouch = e.changedTouches[0];
if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, window.getSelection())) {
return;
}
- this.primaryTouchIdentifier = primaryTouch.identifier;
+ this._primaryTouchIdentifier = primaryTouch.identifier;
- if (this.pendingLookup) {
+ if (this._pendingLookup) {
return;
}
- const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null;
+ const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null;
this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart')
.then(() => {
if (
- this.textSourceCurrent === null ||
- this.textSourceCurrent.equals(textSourceCurrentPrevious)
+ this._textSourceCurrent === null ||
+ this._textSourceCurrent.equals(textSourceCurrentPrevious)
) {
return;
}
- this.preventScroll = true;
- this.preventNextContextMenu = true;
- this.preventNextMouseDown = true;
+ this._preventScroll = true;
+ this._preventNextContextMenu = true;
+ this._preventNextMouseDown = true;
});
}
- onTouchEnd(e) {
+ _onTouchEnd(e) {
if (
- this.primaryTouchIdentifier === null ||
- TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier) === null
+ this._primaryTouchIdentifier === null ||
+ this._getTouch(e.changedTouches, this._primaryTouchIdentifier) === null
) {
return;
}
- this.primaryTouchIdentifier = null;
- this.preventScroll = false;
- this.preventNextClick = false;
- // Don't revert context menu and mouse down prevention,
- // since these events can occur after the touch has ended.
- // this.preventNextContextMenu = false;
- // this.preventNextMouseDown = false;
+ this._primaryTouchIdentifier = null;
+ this._preventScroll = false;
+ this._preventNextClick = false;
+ // Don't revert context menu and mouse down prevention, since these events can occur after the touch has ended.
+ // I.e. this._preventNextContextMenu and this._preventNextMouseDown should not be assigned to false.
}
- onTouchCancel(e) {
- this.onTouchEnd(e);
+ _onTouchCancel(e) {
+ this._onTouchEnd(e);
}
- onTouchMove(e) {
- if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) {
+ _onTouchMove(e) {
+ if (!this._preventScroll || !e.cancelable || this._primaryTouchIdentifier === null) {
return;
}
- const primaryTouch = TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier);
+ const primaryTouch = this._getTouch(e.changedTouches, this._primaryTouchIdentifier);
if (primaryTouch === null) {
return;
}
@@ -196,171 +319,70 @@ class TextScanner {
e.preventDefault(); // Disable scroll
}
- async onSearchSource(_textSource, _cause) {
- throw new Error('Override me');
- }
-
- onError(error) {
- logError(error, false);
- }
-
- async scanTimerWait() {
- const delay = this.options.scanning.delay;
+ async _scanTimerWait() {
+ const delay = this._options.scanning.delay;
const promise = promiseTimeout(delay, true);
- this.scanTimerPromise = promise;
+ this._scanTimerPromise = promise;
try {
return await promise;
} finally {
- if (this.scanTimerPromise === promise) {
- this.scanTimerPromise = null;
+ if (this._scanTimerPromise === promise) {
+ this._scanTimerPromise = null;
}
}
}
- scanTimerClear() {
- if (this.scanTimerPromise !== null) {
- this.scanTimerPromise.resolve(false);
- this.scanTimerPromise = null;
+ _scanTimerClear() {
+ if (this._scanTimerPromise !== null) {
+ this._scanTimerPromise.resolve(false);
+ this._scanTimerPromise = null;
}
}
- setEnabled(enabled, canEnable) {
- if (enabled && canEnable) {
- if (!this.enabled) {
- this.hookEvents();
- this.enabled = true;
- }
- } else {
- if (this.enabled) {
- this.eventListeners.removeAllEventListeners();
- this.enabled = false;
- }
- this.onSearchClear(false);
- }
- }
-
- hookEvents() {
- let eventListenerInfos = this.getMouseEventListeners();
- if (this.options.scanning.touchInputEnabled) {
- eventListenerInfos = eventListenerInfos.concat(this.getTouchEventListeners());
+ _hookEvents() {
+ const eventListenerInfos = this._getMouseEventListeners();
+ if (this._options.scanning.touchInputEnabled) {
+ eventListenerInfos.push(...this._getTouchEventListeners());
}
for (const [node, type, listener, options] of eventListenerInfos) {
- this.eventListeners.addEventListener(node, type, listener, options);
+ this._eventListeners.addEventListener(node, type, listener, options);
}
}
- getMouseEventListeners() {
+ _getMouseEventListeners() {
return [
- [this.node, 'mousedown', this.onMouseDown.bind(this)],
- [this.node, 'mousemove', this.onMouseMove.bind(this)],
- [this.node, 'mouseover', this.onMouseOver.bind(this)],
- [this.node, 'mouseout', this.onMouseOut.bind(this)]
+ [this._node, 'mousedown', this._onMouseDown.bind(this)],
+ [this._node, 'mousemove', this._onMouseMove.bind(this)],
+ [this._node, 'mouseover', this._onMouseOver.bind(this)],
+ [this._node, 'mouseout', this._onMouseOut.bind(this)]
];
}
- getTouchEventListeners() {
+ _getTouchEventListeners() {
return [
- [this.node, 'click', this.onClick.bind(this)],
- [this.node, 'auxclick', this.onAuxClick.bind(this)],
- [this.node, 'touchstart', this.onTouchStart.bind(this)],
- [this.node, 'touchend', this.onTouchEnd.bind(this)],
- [this.node, 'touchcancel', this.onTouchCancel.bind(this)],
- [this.node, 'touchmove', this.onTouchMove.bind(this), {passive: false}],
- [this.node, 'contextmenu', this.onContextMenu.bind(this)]
+ [this._node, 'click', this._onClick.bind(this)],
+ [this._node, 'auxclick', this._onAuxClick.bind(this)],
+ [this._node, 'touchstart', this._onTouchStart.bind(this)],
+ [this._node, 'touchend', this._onTouchEnd.bind(this)],
+ [this._node, 'touchcancel', this._onTouchCancel.bind(this)],
+ [this._node, 'touchmove', this._onTouchMove.bind(this), {passive: false}],
+ [this._node, 'contextmenu', this._onContextMenu.bind(this)]
];
}
- setOptions(options, canEnable=true) {
- this.options = options;
- this.setEnabled(this.options.general.enable, canEnable);
- }
-
- async searchAt(x, y, cause) {
- try {
- this.scanTimerClear();
-
- if (this.pendingLookup) {
- return;
- }
-
- for (const ignorePointFn of this.ignorePoints) {
- if (await ignorePointFn(x, y)) {
- return;
- }
- }
-
- const textSource = docRangeFromPoint(x, y, this.options.scanning.deepDomScan);
- try {
- if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) {
- return;
- }
-
- this.pendingLookup = true;
- const result = await this.onSearchSource(textSource, cause);
- if (result !== null) {
- this.causeCurrent = cause;
- this.textSourceCurrent = textSource;
- if (this.options.scanning.selectText) {
- textSource.select();
- }
- }
- this.pendingLookup = false;
- } finally {
- if (textSource !== null) {
- textSource.cleanup();
- }
- }
- } catch (e) {
- this.onError(e);
- }
- }
-
- setTextSourceScanLength(textSource, length) {
- textSource.setEndOffset(length);
- if (this.ignoreNodes === null || !textSource.range) {
- return;
- }
-
- length = textSource.text().length;
- while (textSource.range && length > 0) {
- const nodes = TextSourceRange.getNodesInRange(textSource.range);
- if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) {
- break;
- }
- --length;
- textSource.setEndOffset(length);
- }
- }
-
- onSearchClear(_) {
- if (this.textSourceCurrent !== null) {
- if (this.options.scanning.selectText) {
- this.textSourceCurrent.deselect();
- }
- this.textSourceCurrent = null;
- }
- }
-
- getCurrentTextSource() {
- return this.textSourceCurrent;
- }
-
- setCurrentTextSource(textSource) {
- return this.textSourceCurrent = textSource;
- }
-
- static isScanningModifierPressed(scanningModifier, mouseEvent) {
+ _isScanningModifierPressed(scanningModifier, mouseEvent) {
switch (scanningModifier) {
case 'alt': return mouseEvent.altKey;
case 'ctrl': return mouseEvent.ctrlKey;
case 'shift': return mouseEvent.shiftKey;
+ case 'meta': return mouseEvent.metaKey;
case 'none': return true;
default: return false;
}
}
- static getTouch(touchList, identifier) {
+ _getTouch(touchList, identifier) {
for (const touch of touchList) {
if (touch.identifier === identifier) {
return touch;