diff options
author | Alex Yatskov <alex@foosoft.net> | 2020-05-22 17:46:16 -0700 |
---|---|---|
committer | Alex Yatskov <alex@foosoft.net> | 2020-05-22 17:46:16 -0700 |
commit | 1480288561cb8b9fb87ad711d970c548329fea98 (patch) | |
tree | 87c2247f6d144407afcc6de316bbacc264582248 /ext | |
parent | f2186c51e4ef219d158735d30a32bbf3e49c4e1a (diff) | |
parent | d0dcff765f740bf6f0f6523b09cb8b21eb85cd93 (diff) |
Merge branch 'master' into testing
Diffstat (limited to 'ext')
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 Binary files differnew file mode 100644 index 00000000..05f2f064 --- /dev/null +++ b/ext/mixed/img/icon32.png diff --git a/ext/mixed/img/yomichan-icon.svg b/ext/mixed/img/yomichan-icon.svg new file mode 100644 index 00000000..f15ab0aa --- /dev/null +++ b/ext/mixed/img/yomichan-icon.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> + <rect width="16" height="16" rx="1.625" ry="1.625"/> + <path d="m2 2v2h3v3h-3v2h3v3h-3v2h5v-12h-5zm7 0v2h5v-2h-5zm0 5v2h5v-2h-5zm0 5v2h5v-2h-5z" fill="#fff"/> +</svg>
\ No newline at end of file diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 30c08347..0bc91759 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -28,10 +28,6 @@ function apiOptionsGetFull() { return _apiInvoke('optionsGetFull'); } -function apiOptionsSet(changedOptions, optionsContext, source) { - return _apiInvoke('optionsSet', {changedOptions, optionsContext, source}); -} - function apiOptionsSave(source) { return _apiInvoke('optionsSave', {source}); } @@ -64,8 +60,8 @@ function apiTemplateRender(template, data) { return _apiInvoke('templateRender', {data, template}); } -function apiAudioGetUri(definition, source, optionsContext) { - return _apiInvoke('audioGetUri', {definition, source, optionsContext}); +function apiAudioGetUri(definition, source, details) { + return _apiInvoke('audioGetUri', {definition, source, details}); } function apiCommandExec(command, params) { @@ -76,6 +72,10 @@ function apiScreenshotGet(options) { return _apiInvoke('screenshotGet', {options}); } +function apiSendMessageToFrame(frameId, action, params) { + return _apiInvoke('sendMessageToFrame', {frameId, action, params}); +} + function apiBroadcastTab(action, params) { return _apiInvoke('broadcastTab', {action, params}); } @@ -108,14 +108,176 @@ function apiGetZoom() { return _apiInvoke('getZoom'); } -function apiGetMessageToken() { - return _apiInvoke('getMessageToken'); -} - function apiGetDefaultAnkiFieldTemplates() { return _apiInvoke('getDefaultAnkiFieldTemplates'); } +function apiGetAnkiDeckNames() { + return _apiInvoke('getAnkiDeckNames'); +} + +function apiGetAnkiModelNames() { + return _apiInvoke('getAnkiModelNames'); +} + +function apiGetAnkiModelFieldNames(modelName) { + return _apiInvoke('getAnkiModelFieldNames', {modelName}); +} + +function apiGetDictionaryInfo() { + return _apiInvoke('getDictionaryInfo'); +} + +function apiGetDictionaryCounts(dictionaryNames, getTotal) { + return _apiInvoke('getDictionaryCounts', {dictionaryNames, getTotal}); +} + +function apiPurgeDatabase() { + return _apiInvoke('purgeDatabase'); +} + +function apiGetMedia(targets) { + return _apiInvoke('getMedia', {targets}); +} + +function apiLog(error, level, context) { + return _apiInvoke('log', {error, level, context}); +} + +function apiLogIndicatorClear() { + return _apiInvoke('logIndicatorClear'); +} + +function apiImportDictionaryArchive(archiveContent, details, onProgress) { + return _apiInvokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress); +} + +function apiDeleteDictionary(dictionaryName, onProgress) { + return _apiInvokeWithProgress('deleteDictionary', {dictionaryName}, onProgress); +} + +function apiModifySettings(targets, source) { + return _apiInvoke('modifySettings', {targets, source}); +} + +function _apiCreateActionPort(timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let portNameResolve; + let portNameReject; + const portNamePromise = new Promise((resolve2, reject2) => { + portNameResolve = resolve2; + portNameReject = reject2; + }); + + const onConnect = async (port) => { + try { + const portName = await portNamePromise; + if (port.name !== portName || timer === null) { return; } + } catch (e) { + return; + } + + clearTimeout(timer); + timer = null; + + chrome.runtime.onConnect.removeListener(onConnect); + resolve(port); + }; + + const onError = (e) => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + chrome.runtime.onConnect.removeListener(onConnect); + portNameReject(e); + reject(e); + }; + + timer = setTimeout(() => onError(new Error('Timeout')), timeout); + + chrome.runtime.onConnect.addListener(onConnect); + _apiInvoke('createActionPort').then(portNameResolve, onError); + }); +} + +function _apiInvokeWithProgress(action, params, onProgress, timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let port = null; + + if (typeof onProgress !== 'function') { + onProgress = () => {}; + } + + const onMessage = (message) => { + switch (message.type) { + case 'ack': + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + break; + case 'progress': + try { + onProgress(...message.data); + } catch (e) { + // NOP + } + break; + case 'complete': + cleanup(); + resolve(message.data); + break; + case 'error': + cleanup(); + reject(jsonToError(message.data)); + break; + } + }; + + const onDisconnect = () => { + cleanup(); + reject(new Error('Disconnected')); + }; + + const cleanup = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + if (port !== null) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + port = null; + } + onProgress = null; + }; + + timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + (async () => { + try { + port = await _apiCreateActionPort(timeout); + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + port.postMessage({action, params}); + } catch (e) { + cleanup(); + reject(e); + } finally { + action = null; + params = null; + } + })(); + }); +} + function _apiInvoke(action, params={}) { const data = {action, params}; return new Promise((resolve, reject) => { @@ -143,3 +305,17 @@ function _apiInvoke(action, params={}) { function _apiCheckLastError() { // NOP } + +let _apiForwardLogsToBackendEnabled = false; +function apiForwardLogsToBackend() { + if (_apiForwardLogsToBackendEnabled) { return; } + _apiForwardLogsToBackendEnabled = true; + + yomichan.on('log', async ({error, level, context}) => { + try { + await apiLog(errorToJson(error), level, context); + } catch (e) { + // NOP + } + }); +} diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js index 45b733fc..fdfb0b10 100644 --- a/ext/mixed/js/audio-system.js +++ b/ext/mixed/js/audio-system.js @@ -40,7 +40,7 @@ class TextToSpeechAudio { } } - play() { + async play() { try { if (this._utterance === null) { this._utterance = new SpeechSynthesisUtterance(this.text || ''); @@ -66,10 +66,10 @@ class TextToSpeechAudio { } class AudioSystem { - constructor({getAudioUri}) { - this._cache = new Map(); + constructor({audioUriBuilder, useCache}) { + this._cache = useCache ? new Map() : null; this._cacheSizeMaximum = 32; - this._getAudioUri = getAudioUri; + this._audioUriBuilder = audioUriBuilder; if (typeof speechSynthesis !== 'undefined') { // speechSynthesis.getVoices() will not be populated unless some API call is made. @@ -79,21 +79,35 @@ class AudioSystem { async getDefinitionAudio(definition, sources, details) { const key = `${definition.expression}:${definition.reading}`; - const cacheValue = this._cache.get(definition); - if (typeof cacheValue !== 'undefined') { - const {audio, uri, source} = cacheValue; - return {audio, uri, source}; + const hasCache = (this._cache !== null && !details.disableCache); + + if (hasCache) { + const cacheValue = this._cache.get(key); + if (typeof cacheValue !== 'undefined') { + const {audio, uri, source} = cacheValue; + const index = sources.indexOf(source); + if (index >= 0) { + return {audio, uri, index}; + } + } } - for (const source of sources) { + for (let i = 0, ii = sources.length; i < ii; ++i) { + const source = sources[i]; const uri = await this._getAudioUri(definition, source, details); if (uri === null) { continue; } try { - const audio = await this._createAudio(uri, details); - this._cacheCheck(); - this._cache.set(key, {audio, uri, source}); - return {audio, uri, source}; + const audio = ( + details.binary ? + await this._createAudioBinary(uri) : + await this._createAudio(uri) + ); + if (hasCache) { + this._cacheCheck(); + this._cache.set(key, {audio, uri, source}); + } + return {audio, uri, index: i}; } catch (e) { // NOP } @@ -102,7 +116,7 @@ class AudioSystem { throw new Error('Could not create audio'); } - createTextToSpeechAudio({text, voiceUri}) { + createTextToSpeechAudio(text, voiceUri) { const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri); if (voice === null) { throw new Error('Invalid text-to-speech voice'); @@ -114,27 +128,38 @@ class AudioSystem { // NOP } - async _createAudio(uri, details) { + _getAudioUri(definition, source, details) { + return ( + this._audioUriBuilder !== null ? + this._audioUriBuilder.getUri(definition, source, details) : + null + ); + } + + async _createAudio(uri) { const ttsParameters = this._getTextToSpeechParameters(uri); if (ttsParameters !== null) { - if (typeof details === 'object' && details !== null) { - if (details.tts === false) { - throw new Error('Text-to-speech not permitted'); - } - } - return this.createTextToSpeechAudio(ttsParameters); + const {text, voiceUri} = ttsParameters; + return this.createTextToSpeechAudio(text, voiceUri); } return await this._createAudioFromUrl(uri); } + async _createAudioBinary(uri) { + const ttsParameters = this._getTextToSpeechParameters(uri); + if (ttsParameters !== null) { + throw new Error('Cannot create audio from text-to-speech'); + } + + return await this._createAudioBinaryFromUrl(uri); + } + _createAudioFromUrl(url) { return new Promise((resolve, reject) => { const audio = new Audio(url); audio.addEventListener('loadeddata', () => { - const duration = audio.duration; - if (duration === 5.694694 || duration === 5.720718) { - // Hardcoded values for invalid audio + if (!this._isAudioValid(audio)) { reject(new Error('Could not retrieve audio')); } else { resolve(audio); @@ -144,6 +169,42 @@ class AudioSystem { }); } + _createAudioBinaryFromUrl(url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = 'arraybuffer'; + xhr.addEventListener('load', async () => { + const arrayBuffer = xhr.response; + if (!await this._isAudioBinaryValid(arrayBuffer)) { + reject(new Error('Could not retrieve audio')); + } else { + resolve(arrayBuffer); + } + }); + xhr.addEventListener('error', () => reject(new Error('Failed to connect'))); + xhr.open('GET', url); + xhr.send(); + }); + } + + _isAudioValid(audio) { + const duration = audio.duration; + return ( + duration !== 5.694694 && // jpod101 invalid audio (Chrome) + duration !== 5.720718 // jpod101 invalid audio (Firefox) + ); + } + + async _isAudioBinaryValid(arrayBuffer) { + const digest = await AudioSystem.arrayBufferDigest(arrayBuffer); + switch (digest) { + case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // jpod101 invalid audio + return false; + default: + return true; + } + } + _getTextToSpeechVoiceFromVoiceUri(voiceUri) { try { for (const voice of speechSynthesis.getVoices()) { @@ -181,4 +242,13 @@ class AudioSystem { this._cache.delete(key); } } + + static async arrayBufferDigest(arrayBuffer) { + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer))); + let digest = ''; + for (const byte of hash) { + digest += byte.toString(16).padStart(2, '0'); + } + return digest; + } } diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 2d11c11a..589425f2 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -52,15 +52,28 @@ if (EXTENSION_IS_BROWSER_EDGE) { */ function errorToJson(error) { + try { + if (isObject(error)) { + return { + name: error.name, + message: error.message, + stack: error.stack, + data: error.data + }; + } + } catch (e) { + // NOP + } return { - name: error.name, - message: error.message, - stack: error.stack, - data: error.data + value: error, + hasValue: true }; } function jsonToError(jsonError) { + if (jsonError.hasValue) { + return jsonError.value; + } const error = new Error(jsonError.message); error.name = jsonError.name; error.stack = jsonError.stack; @@ -68,28 +81,6 @@ function jsonToError(jsonError) { return error; } -function logError(error, alert) { - const manifest = chrome.runtime.getManifest(); - let errorMessage = `${manifest.name} v${manifest.version} has encountered an error.\n`; - errorMessage += `Originating URL: ${window.location.href}\n`; - - const errorString = `${error.toString ? error.toString() : error}`; - const stack = `${error.stack}`.trimRight(); - if (!stack.startsWith(errorString)) { errorMessage += `${errorString}\n`; } - errorMessage += stack; - - const data = error.data; - if (typeof data !== 'undefined') { errorMessage += `\nData: ${JSON.stringify(data, null, 4)}`; } - - errorMessage += '\n\nIssues can be reported at https://github.com/FooSoft/yomichan/issues'; - - console.error(errorMessage); - - if (alert) { - window.alert(`${errorString}\n\nCheck the developer console for more details.`); - } -} - /* * Common helpers @@ -103,6 +94,11 @@ function hasOwn(object, property) { return Object.prototype.hasOwnProperty.call(object, property); } +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions +function escapeRegExp(string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); +} + // toIterable is required on Edge for cross-window origin objects. function toIterable(value) { if (typeof Symbol !== 'undefined' && typeof value[Symbol.iterator] !== 'undefined') { @@ -155,6 +151,12 @@ function getSetIntersection(set1, set2) { return result; } +function getSetDifference(set1, set2) { + return new Set( + [...set1].filter((value) => !set2.has(value)) + ); +} + /* * Async utilities @@ -316,6 +318,15 @@ const yomichan = (() => { this.trigger('orphaned', {error}); } + isExtensionUrl(url) { + try { + const urlBase = chrome.runtime.getURL('/'); + return url.substring(0, urlBase.length) === urlBase; + } catch (e) { + return false; + } + } + getTemporaryListenerResult(eventHandler, userCallback, timeout=null) { if (!( typeof eventHandler.addListener === 'function' && @@ -352,8 +363,77 @@ const yomichan = (() => { }); } + logWarning(error) { + this.log(error, 'warn'); + } + + logError(error) { + this.log(error, 'error'); + } + + log(error, level, context=null) { + if (!isObject(context)) { + context = this._getLogContext(); + } + + let errorString; + try { + errorString = error.toString(); + if (/^\[object \w+\]$/.test(errorString)) { + errorString = JSON.stringify(error); + } + } catch (e) { + errorString = `${error}`; + } + + let errorStack; + try { + errorStack = (typeof error.stack === 'string' ? error.stack.trimRight() : ''); + } catch (e) { + errorStack = ''; + } + + let errorData; + try { + errorData = error.data; + } catch (e) { + // NOP + } + + if (errorStack.startsWith(errorString)) { + errorString = errorStack; + } else if (errorStack.length > 0) { + errorString += `\n${errorStack}`; + } + + const manifest = chrome.runtime.getManifest(); + let message = `${manifest.name} v${manifest.version} has encountered a problem.`; + message += `\nOriginating URL: ${context.url}\n`; + message += errorString; + if (typeof errorData !== 'undefined') { + message += `\nData: ${JSON.stringify(errorData, null, 4)}`; + } + message += '\n\nIssues can be reported at https://github.com/FooSoft/yomichan/issues'; + + switch (level) { + case 'info': console.info(message); break; + case 'debug': console.debug(message); break; + case 'warn': console.warn(message); break; + case 'error': console.error(message); break; + default: console.log(message); break; + } + + this.trigger('log', {error, level, context}); + } + // Private + _getLogContext() { + return { + url: window.location.href + }; + } + _onMessage({action, params}, sender, callback) { const handler = this._messageHandlers.get(action); if (typeof handler !== 'function') { return false; } diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index 0f991362..a2b2b139 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -22,7 +22,8 @@ */ class DisplayGenerator { - constructor() { + constructor({mediaLoader}) { + this._mediaLoader = mediaLoader; this._templateHandler = null; this._termPitchAccentStaticTemplateIsSetup = false; } @@ -176,16 +177,30 @@ class DisplayGenerator { const onlyListContainer = node.querySelector('.term-definition-disambiguation-list'); const glossaryContainer = node.querySelector('.term-glossary-list'); - node.dataset.dictionary = details.dictionary; + const dictionary = details.dictionary; + node.dataset.dictionary = dictionary; this._appendMultiple(tagListContainer, this._createTag.bind(this), details.definitionTags); this._appendMultiple(onlyListContainer, this._createTermDisambiguation.bind(this), details.only); - this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary); + this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary, dictionary); return node; } - _createTermGlossaryItem(glossary) { + _createTermGlossaryItem(glossary, dictionary) { + if (typeof glossary === 'string') { + return this._createTermGlossaryItemText(glossary); + } else if (typeof glossary === 'object' && glossary !== null) { + switch (glossary.type) { + case 'image': + return this._createTermGlossaryItemImage(glossary, dictionary); + } + } + + return null; + } + + _createTermGlossaryItemText(glossary) { const node = this._templateHandler.instantiate('term-glossary-item'); const container = node.querySelector('.term-glossary'); if (container !== null) { @@ -194,6 +209,68 @@ class DisplayGenerator { return node; } + _createTermGlossaryItemImage(data, dictionary) { + const {path, width, height, preferredWidth, preferredHeight, title, description, pixelated} = data; + + const usedWidth = ( + typeof preferredWidth === 'number' ? + preferredWidth : + width + ); + const aspectRatio = ( + typeof preferredWidth === 'number' && + typeof preferredHeight === 'number' ? + preferredWidth / preferredHeight : + width / height + ); + + const node = this._templateHandler.instantiate('term-glossary-item-image'); + node.dataset.path = path; + node.dataset.dictionary = dictionary; + node.dataset.imageLoadState = 'not-loaded'; + + const imageContainer = node.querySelector('.term-glossary-image-container'); + imageContainer.style.width = `${usedWidth}em`; + if (typeof title === 'string') { + imageContainer.title = title; + } + + const aspectRatioSizer = node.querySelector('.term-glossary-image-aspect-ratio-sizer'); + aspectRatioSizer.style.paddingTop = `${aspectRatio * 100.0}%`; + + const image = node.querySelector('img.term-glossary-image'); + const imageLink = node.querySelector('.term-glossary-image-link'); + image.dataset.pixelated = `${pixelated === true}`; + + if (this._mediaLoader !== null) { + this._mediaLoader.loadMedia( + path, + dictionary, + (url) => this._setImageData(node, image, imageLink, url, false), + () => this._setImageData(node, image, imageLink, null, true) + ); + } + + if (typeof description === 'string') { + const container = node.querySelector('.term-glossary-image-description'); + this._appendMultilineText(container, description); + } + + return node; + } + + _setImageData(container, image, imageLink, url, unloaded) { + if (url !== null) { + image.src = url; + imageLink.href = url; + container.dataset.imageLoadState = 'loaded'; + } else { + image.removeAttribute('src'); + imageLink.removeAttribute('href'); + container.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error'; + } + } + _createTermDisambiguation(disambiguation) { const node = this._templateHandler.instantiate('term-definition-disambiguation'); node.dataset.term = disambiguation; diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 63687dc2..2e59b4ff 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -20,6 +20,7 @@ * DOM * DisplayContext * DisplayGenerator + * MediaLoader * WindowScroll * apiAudioGetUri * apiBroadcastTab @@ -45,7 +46,14 @@ class Display { this.index = 0; this.audioPlaying = null; this.audioFallback = null; - this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)}); + this.audioSystem = new AudioSystem({ + audioUriBuilder: { + getUri: async (definition, source, details) => { + return await apiAudioGetUri(definition, source, details); + } + }, + useCache: true + }); this.styleNode = null; this.eventListeners = new EventListenerCollection(); @@ -55,12 +63,13 @@ class Display { this.clickScanPrevent = false; this.setContentToken = null; - this.displayGenerator = new DisplayGenerator(); + this.mediaLoader = new MediaLoader(); + this.displayGenerator = new DisplayGenerator({mediaLoader: this.mediaLoader}); this.windowScroll = new WindowScroll(); this._onKeyDownHandlers = new Map([ ['Escape', () => { - this.onSearchClear(); + this.onEscape(); return true; }], ['PageUp', (e) => { @@ -168,15 +177,13 @@ class Display { async prepare() { await yomichan.prepare(); await this.displayGenerator.prepare(); - await this.updateOptions(); - yomichan.on('optionsUpdated', () => this.updateOptions()); } onError(_error) { throw new Error('Override me'); } - onSearchClear() { + onEscape() { throw new Error('Override me'); } @@ -331,7 +338,7 @@ class Display { } onKeyDown(e) { - const key = Display.getKeyFromEvent(e); + const key = DOM.getKeyFromEvent(e); const handler = this._onKeyDownHandlers.get(key); if (typeof handler === 'function') { if (handler(e)) { @@ -392,12 +399,6 @@ class Display { updateTheme(themeName) { document.documentElement.dataset.yomichanTheme = themeName; - - const stylesheets = document.querySelectorAll('link[data-yomichan-theme-name]'); - for (const stylesheet of stylesheets) { - const match = (stylesheet.dataset.yomichanThemeName === themeName); - stylesheet.rel = (match ? 'stylesheet' : 'stylesheet alternate'); - } } setCustomCss(css) { @@ -472,6 +473,8 @@ class Display { const token = {}; // Unique identifier token this.setContentToken = token; try { + this.mediaLoader.unloadAll(); + switch (type) { case 'terms': await this.setContentTerms(details.definitions, details.context, token); @@ -784,16 +787,14 @@ class Display { const expression = expressionIndex === -1 ? definition : definition.expressions[expressionIndex]; - if (this.audioPlaying !== null) { - this.audioPlaying.pause(); - this.audioPlaying = null; - } + this._stopPlayingAudio(); - const sources = this.options.audio.sources; - let audio, source, info; + let audio, info; try { - ({audio, source} = await this.audioSystem.getDefinitionAudio(expression, sources)); - info = `From source ${1 + sources.indexOf(source)}: ${source}`; + const {sources, textToSpeechVoice, customSourceUrl} = this.options.audio; + let index; + ({audio, index} = await this.audioSystem.getDefinitionAudio(expression, sources, {textToSpeechVoice, customSourceUrl})); + info = `From source ${1 + index}: ${sources[index]}`; } catch (e) { if (this.audioFallback === null) { this.audioFallback = new Audio('/mixed/mp3/button.mp3'); @@ -802,7 +803,7 @@ class Display { info = 'Could not find audio'; } - const button = this.audioButtonFindImage(entryIndex); + const button = this.audioButtonFindImage(entryIndex, expressionIndex); if (button !== null) { let titleDefault = button.dataset.titleDefault; if (!titleDefault) { @@ -812,10 +813,19 @@ class Display { button.title = `${titleDefault}\n${info}`; } + this._stopPlayingAudio(); + this.audioPlaying = audio; audio.currentTime = 0; audio.volume = this.options.audio.volume / 100.0; - audio.play(); + const playPromise = audio.play(); + if (typeof playPromise !== 'undefined') { + try { + await playPromise; + } catch (e2) { + // NOP + } + } } catch (e) { this.onError(e); } finally { @@ -823,6 +833,13 @@ class Display { } } + _stopPlayingAudio() { + if (this.audioPlaying !== null) { + this.audioPlaying.pause(); + this.audioPlaying = null; + } + } + noteUsesScreenshot(mode) { const optionsAnki = this.options.anki; const fields = (mode === 'kanji' ? optionsAnki.kanji : optionsAnki.terms).fields; @@ -901,9 +918,16 @@ class Display { viewerButton.dataset.noteId = noteId; } - audioButtonFindImage(index) { + audioButtonFindImage(index, expressionIndex) { const entry = this.getEntry(index); - return entry !== null ? entry.querySelector('.action-play-audio>img') : null; + if (entry === null) { return null; } + + const container = ( + expressionIndex >= 0 ? + entry.querySelector(`.term-expression:nth-of-type(${expressionIndex + 1})`) : + entry + ); + return container !== null ? container.querySelector('.action-play-audio>img') : null; } async getDefinitionsAddable(definitions, modes) { @@ -934,11 +958,6 @@ class Display { return elementRect.top - documentRect.top; } - static getKeyFromEvent(event) { - const key = event.key; - return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); - } - async _getNoteContext() { const documentTitle = await this.getDocumentTitle(); return { @@ -947,9 +966,4 @@ class Display { } }; } - - async _getAudioUri(definition, source) { - const optionsContext = this.getOptionsContext(); - return await apiAudioGetUri(definition, source, optionsContext); - } } diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js index 03acbb80..0e8f4462 100644 --- a/ext/mixed/js/dom.js +++ b/ext/mixed/js/dom.js @@ -62,4 +62,28 @@ class DOM { default: return false; } } + + static getActiveModifiers(event) { + const modifiers = new Set(); + if (event.altKey) { modifiers.add('alt'); } + if (event.ctrlKey) { modifiers.add('ctrl'); } + if (event.metaKey) { modifiers.add('meta'); } + if (event.shiftKey) { modifiers.add('shift'); } + return modifiers; + } + + static getKeyFromEvent(event) { + const key = event.key; + return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); + } + + static getFullscreenElement() { + return ( + document.fullscreenElement || + document.msFullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + null + ); + } } diff --git a/ext/mixed/js/dynamic-loader-sentinel.js b/ext/mixed/js/dynamic-loader-sentinel.js new file mode 100644 index 00000000..f783bdb7 --- /dev/null +++ b/ext/mixed/js/dynamic-loader-sentinel.js @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +yomichan.trigger('dynamicLoaderSentinel', {script: document.currentScript}); diff --git a/ext/mixed/js/dynamic-loader.js b/ext/mixed/js/dynamic-loader.js new file mode 100644 index 00000000..ce946109 --- /dev/null +++ b/ext/mixed/js/dynamic-loader.js @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * apiInjectStylesheet + */ + +const dynamicLoader = (() => { + const injectedStylesheets = new Map(); + + async function loadStyle(id, type, value, useWebExtensionApi=false) { + if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) { + // Permissions error will occur if trying to use the WebExtension API to inject into an extension page + useWebExtensionApi = false; + } + + let styleNode = injectedStylesheets.get(id); + if (typeof styleNode !== 'undefined') { + if (styleNode === null) { + // Previously injected via WebExtension API + throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); + } + } else { + styleNode = null; + } + + if (useWebExtensionApi) { + // Inject via WebExtension API + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + + injectedStylesheets.set(id, null); + await apiInjectStylesheet(type, value); + return null; + } + + // Create node in document + const parentNode = document.head; + if (parentNode === null) { + throw new Error('No parent node'); + } + + // Create or reuse node + const isFile = (type === 'file'); + const tagName = isFile ? 'link' : 'style'; + if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + styleNode = document.createElement(tagName); + } + + // Update node style + if (isFile) { + styleNode.rel = 'stylesheet'; + styleNode.href = value; + } else { + styleNode.textContent = value; + } + + // Update parent + if (styleNode.parentNode !== parentNode) { + parentNode.appendChild(styleNode); + } + + // Add to map + injectedStylesheets.set(id, styleNode); + return styleNode; + } + + function loadScripts(urls) { + return new Promise((resolve, reject) => { + const parent = document.body; + if (parent === null) { + reject(new Error('Missing body')); + return; + } + + for (const url of urls) { + const node = parent.querySelector(`script[src='${escapeCSSAttribute(url)}']`); + if (node !== null) { continue; } + + const script = document.createElement('script'); + script.async = false; + script.src = url; + parent.appendChild(script); + } + + loadScriptSentinel(parent, resolve, reject); + }); + } + + function loadScriptSentinel(parent, resolve, reject) { + const script = document.createElement('script'); + + const sentinelEventName = 'dynamicLoaderSentinel'; + const sentinelEventCallback = (e) => { + if (e.script !== script) { return; } + yomichan.off(sentinelEventName, sentinelEventCallback); + parent.removeChild(script); + resolve(); + }; + yomichan.on(sentinelEventName, sentinelEventCallback); + + try { + script.async = false; + script.src = '/mixed/js/dynamic-loader-sentinel.js'; + parent.appendChild(script); + } catch (e) { + yomichan.off(sentinelEventName, sentinelEventCallback); + reject(e); + } + } + + function escapeCSSAttribute(value) { + return value.replace(/['\\]/g, (character) => `\\${character}`); + } + + + return { + loadStyle, + loadScripts + }; +})(); diff --git a/ext/mixed/js/environment.js b/ext/mixed/js/environment.js new file mode 100644 index 00000000..e5bc20a7 --- /dev/null +++ b/ext/mixed/js/environment.js @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + + +class Environment { + constructor() { + this._cachedEnvironmentInfo = null; + } + + async prepare() { + this._cachedEnvironmentInfo = await this._loadEnvironmentInfo(); + } + + getInfo() { + if (this._cachedEnvironmentInfo === null) { throw new Error('Not prepared'); } + return this._cachedEnvironmentInfo; + } + + async _loadEnvironmentInfo() { + const browser = await this._getBrowser(); + const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); + const modifierInfo = this._getModifierInfo(browser, platform.os); + return { + browser, + platform: { + os: platform.os + }, + modifiers: modifierInfo + }; + } + + async _getBrowser() { + if (EXTENSION_IS_BROWSER_EDGE) { + return 'edge'; + } + if (typeof browser !== 'undefined') { + try { + const info = await browser.runtime.getBrowserInfo(); + if (info.name === 'Fennec') { + return 'firefox-mobile'; + } + } catch (e) { + // NOP + } + return 'firefox'; + } else { + return 'chrome'; + } + } + + _getModifierInfo(browser, os) { + let osKeys; + let separator; + switch (os) { + case 'win': + separator = ' + '; + osKeys = [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Windows'] + ]; + break; + case 'mac': + separator = ''; + osKeys = [ + ['alt', '⌥'], + ['ctrl', '⌃'], + ['shift', '⇧'], + ['meta', '⌘'] + ]; + break; + case 'linux': + case 'openbsd': + case 'cros': + case 'android': + separator = ' + '; + osKeys = [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Super'] + ]; + break; + default: + throw new Error(`Invalid OS: ${os}`); + } + + const isFirefox = (browser === 'firefox' || browser === 'firefox-mobile'); + const keys = []; + + for (const [value, name] of osKeys) { + // Firefox doesn't support event.metaKey on platforms other than macOS + if (value === 'meta' && isFirefox && os !== 'mac') { continue; } + keys.push({value, name}); + } + + return {keys, separator}; + } +} diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js index 79d69946..801dec84 100644 --- a/ext/mixed/js/japanese.js +++ b/ext/mixed/js/japanese.js @@ -16,6 +16,11 @@ */ const jp = (() => { + const ITERATION_MARK_CODE_POINT = 0x3005; + const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063; + const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3; + const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc; + const HIRAGANA_RANGE = [0x3040, 0x309f]; const KATAKANA_RANGE = [0x30a0, 0x30ff]; const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE]; @@ -65,20 +70,65 @@ const jp = (() => { const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ')); + const HALFWIDTH_KATAKANA_MAPPING = new Map([ + ['ヲ', 'ヲヺ-'], + ['ァ', 'ァ--'], + ['ィ', 'ィ--'], + ['ゥ', 'ゥ--'], + ['ェ', 'ェ--'], + ['ォ', 'ォ--'], + ['ャ', 'ャ--'], + ['ュ', 'ュ--'], + ['ョ', 'ョ--'], + ['ッ', 'ッ--'], + ['ー', 'ー--'], + ['ア', 'ア--'], + ['イ', 'イ--'], + ['ウ', 'ウヴ-'], + ['エ', 'エ--'], + ['オ', 'オ--'], + ['カ', 'カガ-'], + ['キ', 'キギ-'], + ['ク', 'クグ-'], + ['ケ', 'ケゲ-'], + ['コ', 'コゴ-'], + ['サ', 'サザ-'], + ['シ', 'シジ-'], + ['ス', 'スズ-'], + ['セ', 'セゼ-'], + ['ソ', 'ソゾ-'], + ['タ', 'タダ-'], + ['チ', 'チヂ-'], + ['ツ', 'ツヅ-'], + ['テ', 'テデ-'], + ['ト', 'トド-'], + ['ナ', 'ナ--'], + ['ニ', 'ニ--'], + ['ヌ', 'ヌ--'], + ['ネ', 'ネ--'], + ['ノ', 'ノ--'], + ['ハ', 'ハバパ'], + ['ヒ', 'ヒビピ'], + ['フ', 'フブプ'], + ['ヘ', 'ヘベペ'], + ['ホ', 'ホボポ'], + ['マ', 'マ--'], + ['ミ', 'ミ--'], + ['ム', 'ム--'], + ['メ', 'メ--'], + ['モ', 'モ--'], + ['ヤ', 'ヤ--'], + ['ユ', 'ユ--'], + ['ヨ', 'ヨ--'], + ['ラ', 'ラ--'], + ['リ', 'リ--'], + ['ル', 'ル--'], + ['レ', 'レ--'], + ['ロ', 'ロ--'], + ['ワ', 'ワ--'], + ['ン', 'ン--'] + ]); - // Character code testing functions - - function isCodePointKanji(codePoint) { - return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES); - } - - function isCodePointKana(codePoint) { - return isCodePointInRanges(codePoint, KANA_RANGES); - } - - function isCodePointJapanese(codePoint) { - return isCodePointInRanges(codePoint, JAPANESE_RANGES); - } function isCodePointInRanges(codePoint, ranges) { for (const [min, max] of ranges) { @@ -89,59 +139,410 @@ const jp = (() => { return false; } + function getWanakana() { + try { + if (typeof wanakana !== 'undefined') { + // eslint-disable-next-line no-undef + return wanakana; + } + } catch (e) { + // NOP + } + return null; + } + + + class JapaneseUtil { + constructor(wanakana=null) { + this._wanakana = wanakana; + } + + // Character code testing functions + + isCodePointKanji(codePoint) { + return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES); + } + + isCodePointKana(codePoint) { + return isCodePointInRanges(codePoint, KANA_RANGES); + } - // String testing functions + isCodePointJapanese(codePoint) { + return isCodePointInRanges(codePoint, JAPANESE_RANGES); + } + + // String testing functions - function isStringEntirelyKana(str) { - if (str.length === 0) { return false; } - for (const c of str) { - if (!isCodePointKana(c.codePointAt(0))) { - return false; + isStringEntirelyKana(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (!isCodePointInRanges(c.codePointAt(0), KANA_RANGES)) { + return false; + } } + return true; } - return true; - } - function isStringPartiallyJapanese(str) { - if (str.length === 0) { return false; } - for (const c of str) { - if (isCodePointJapanese(c.codePointAt(0))) { - return true; + isStringPartiallyJapanese(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (isCodePointInRanges(c.codePointAt(0), JAPANESE_RANGES)) { + return true; + } } + return false; } - return false; - } + // Mora functions - // Mora functions + isMoraPitchHigh(moraIndex, pitchAccentPosition) { + switch (pitchAccentPosition) { + case 0: return (moraIndex > 0); + case 1: return (moraIndex < 1); + default: return (moraIndex > 0 && moraIndex < pitchAccentPosition); + } + } - function isMoraPitchHigh(moraIndex, pitchAccentPosition) { - return pitchAccentPosition === 0 ? (moraIndex > 0) : (moraIndex < pitchAccentPosition); - } + getKanaMorae(text) { + const morae = []; + let i; + for (const c of text) { + if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) { + morae[i - 1] += c; + } else { + morae.push(c); + } + } + return morae; + } + + // Conversion functions - function getKanaMorae(text) { - const morae = []; - let i; - for (const c of text) { - if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) { - morae[i - 1] += c; - } else { - morae.push(c); + convertKatakanaToHiragana(text) { + const wanakana = this._getWanakana(); + let result = ''; + for (const c of text) { + if (wanakana.isKatakana(c)) { + result += wanakana.toHiragana(c); + } else { + result += c; + } } + + return result; } - return morae; - } + convertHiraganaToKatakana(text) { + const wanakana = this._getWanakana(); + let result = ''; + for (const c of text) { + if (wanakana.isHiragana(c)) { + result += wanakana.toKatakana(c); + } else { + result += c; + } + } + + return result; + } + + convertToRomaji(text) { + const wanakana = this._getWanakana(); + return wanakana.toRomaji(text); + } + + convertReading(expression, reading, readingMode) { + switch (readingMode) { + case 'hiragana': + return this.convertKatakanaToHiragana(reading); + case 'katakana': + return this.convertHiraganaToKatakana(reading); + case 'romaji': + if (reading) { + return this.convertToRomaji(reading); + } else { + if (this.isStringEntirelyKana(expression)) { + return this.convertToRomaji(expression); + } + } + return reading; + case 'none': + return ''; + default: + return reading; + } + } + + convertNumericToFullWidth(text) { + let result = ''; + for (const char of text) { + let c = char.codePointAt(0); + if (c >= 0x30 && c <= 0x39) { // ['0', '9'] + c += 0xff10 - 0x30; // 0xff10 = '0' full width + result += String.fromCodePoint(c); + } else { + result += char; + } + } + return result; + } + + convertHalfWidthKanaToFullWidth(text, sourceMap=null) { + let result = ''; + + // This function is safe to use charCodeAt instead of codePointAt, since all + // the relevant characters are represented with a single UTF-16 character code. + for (let i = 0, ii = text.length; i < ii; ++i) { + const c = text[i]; + const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c); + if (typeof mapping !== 'string') { + result += c; + continue; + } + + let index = 0; + switch (text.charCodeAt(i + 1)) { + case 0xff9e: // dakuten + index = 1; + break; + case 0xff9f: // handakuten + index = 2; + break; + } + + let c2 = mapping[index]; + if (index > 0) { + if (c2 === '-') { // invalid + index = 0; + c2 = mapping[0]; + } else { + ++i; + } + } + + if (sourceMap !== null && index > 0) { + sourceMap.combine(result.length, 1); + } + result += c2; + } + + return result; + } + + convertAlphabeticToKana(text, sourceMap=null) { + let part = ''; + let result = ''; + + for (const char of text) { + // Note: 0x61 is the character code for 'a' + let c = char.codePointAt(0); + if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] + c += (0x61 - 0x41); + } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] + // NOP; c += (0x61 - 0x61); + } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth + c += (0x61 - 0xff21); + } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth + c += (0x61 - 0xff41); + } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash + c = 0x2d; // '-' + } else { + if (part.length > 0) { + result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + part = ''; + } + result += char; + continue; + } + part += String.fromCodePoint(c); + } + + if (part.length > 0) { + result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + } + return result; + } + + // Furigana distribution + + distributeFurigana(expression, reading) { + const fallback = [{furigana: reading, text: expression}]; + if (!reading) { + return fallback; + } + + let isAmbiguous = false; + const segmentize = (reading2, groups) => { + if (groups.length === 0 || isAmbiguous) { + return []; + } + + const group = groups[0]; + if (group.mode === 'kana') { + if (this.convertKatakanaToHiragana(reading2).startsWith(this.convertKatakanaToHiragana(group.text))) { + const readingLeft = reading2.substring(group.text.length); + const segs = segmentize(readingLeft, groups.splice(1)); + if (segs) { + return [{text: group.text, furigana: ''}].concat(segs); + } + } + } else { + let foundSegments = null; + for (let i = reading2.length; i >= group.text.length; --i) { + const readingUsed = reading2.substring(0, i); + const readingLeft = reading2.substring(i); + const segs = segmentize(readingLeft, groups.slice(1)); + if (segs) { + if (foundSegments !== null) { + // more than one way to segmentize the tail, mark as ambiguous + isAmbiguous = true; + return null; + } + foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs); + } + // there is only one way to segmentize the last non-kana group + if (groups.length === 1) { + break; + } + } + return foundSegments; + } + }; + + const groups = []; + let modePrev = null; + for (const c of expression) { + const codePoint = c.codePointAt(0); + const modeCurr = this.isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT ? 'kanji' : 'kana'; + if (modeCurr === modePrev) { + groups[groups.length - 1].text += c; + } else { + groups.push({mode: modeCurr, text: c}); + modePrev = modeCurr; + } + } + + const segments = segmentize(reading, groups); + if (segments && !isAmbiguous) { + return segments; + } + return fallback; + } + + distributeFuriganaInflected(expression, reading, source) { + const output = []; + + let stemLength = 0; + const shortest = Math.min(source.length, expression.length); + const sourceHiragana = this.convertKatakanaToHiragana(source); + const expressionHiragana = this.convertKatakanaToHiragana(expression); + while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) { + ++stemLength; + } + const offset = source.length - stemLength; + + const stemExpression = source.substring(0, source.length - offset); + const stemReading = reading.substring( + 0, + offset === 0 ? reading.length : reading.length - expression.length + stemLength + ); + for (const segment of this.distributeFurigana(stemExpression, stemReading)) { + output.push(segment); + } + + if (stemLength !== source.length) { + output.push({text: source.substring(stemLength), furigana: ''}); + } + + return output; + } + + // Miscellaneous + + collapseEmphaticSequences(text, fullCollapse, sourceMap=null) { + let result = ''; + let collapseCodePoint = -1; + const hasSourceMap = (sourceMap !== null); + for (const char of text) { + const c = char.codePointAt(0); + if ( + c === HIRAGANA_SMALL_TSU_CODE_POINT || + c === KATAKANA_SMALL_TSU_CODE_POINT || + c === KANA_PROLONGED_SOUND_MARK_CODE_POINT + ) { + if (collapseCodePoint !== c) { + collapseCodePoint = c; + if (!fullCollapse) { + result += char; + continue; + } + } + } else { + collapseCodePoint = -1; + result += char; + continue; + } + + if (hasSourceMap) { + sourceMap.combine(Math.max(0, result.length - 1), 1); + } + } + return result; + } + + // Private + + _getWanakana() { + const wanakana = this._wanakana; + if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); } + return wanakana; + } + + _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) { + const wanakana = this._getWanakana(); + const result = wanakana.toHiragana(text); + + // Generate source mapping + if (sourceMap !== null) { + let i = 0; + let resultPos = 0; + const ii = text.length; + while (i < ii) { + // Find smallest matching substring + let iNext = i + 1; + let resultPosNext = result.length; + while (iNext < ii) { + const t = wanakana.toHiragana(text.substring(0, iNext)); + if (t === result.substring(0, t.length)) { + resultPosNext = t.length; + break; + } + ++iNext; + } + + // Merge characters + const removals = iNext - i - 1; + if (removals > 0) { + sourceMap.combine(sourceMapStart, removals); + } + ++sourceMapStart; + + // Empty elements + const additions = resultPosNext - resultPos - 1; + for (let j = 0; j < additions; ++j) { + sourceMap.insert(sourceMapStart, 0); + ++sourceMapStart; + } + + i = iNext; + resultPos = resultPosNext; + } + } + + return result; + } + } - // Exports - return { - isCodePointKanji, - isCodePointKana, - isCodePointJapanese, - isStringEntirelyKana, - isStringPartiallyJapanese, - isMoraPitchHigh, - getKanaMorae - }; + return new JapaneseUtil(getWanakana()); })(); diff --git a/ext/mixed/js/media-loader.js b/ext/mixed/js/media-loader.js new file mode 100644 index 00000000..64ccd715 --- /dev/null +++ b/ext/mixed/js/media-loader.js @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * apiGetMedia + */ + +class MediaLoader { + constructor() { + this._token = {}; + this._mediaCache = new Map(); + this._loadMediaData = []; + } + + async loadMedia(path, dictionaryName, onLoad, onUnload) { + const token = this.token; + const data = {onUnload, loaded: false}; + + this._loadMediaData.push(data); + + const media = await this.getMedia(path, dictionaryName); + if (token !== this.token) { return; } + + onLoad(media.url); + data.loaded = true; + } + + unloadAll() { + for (const {onUnload, loaded} of this._loadMediaData) { + if (typeof onUnload === 'function') { + onUnload(loaded); + } + } + this._loadMediaData = []; + + for (const map of this._mediaCache.values()) { + for (const {url} of map.values()) { + if (url !== null) { + URL.revokeObjectURL(url); + } + } + } + this._mediaCache.clear(); + + this._token = {}; + } + + async getMedia(path, dictionaryName) { + let cachedData; + let dictionaryCache = this._mediaCache.get(dictionaryName); + if (typeof dictionaryCache !== 'undefined') { + cachedData = dictionaryCache.get(path); + } else { + dictionaryCache = new Map(); + this._mediaCache.set(dictionaryName, dictionaryCache); + } + + if (typeof cachedData === 'undefined') { + cachedData = { + promise: null, + data: null, + url: null + }; + dictionaryCache.set(path, cachedData); + cachedData.promise = this._getMediaData(path, dictionaryName, cachedData); + } + + return cachedData.promise; + } + + async _getMediaData(path, dictionaryName, cachedData) { + const token = this._token; + const data = (await apiGetMedia([{path, dictionaryName}]))[0]; + if (token === this._token && data !== null) { + const contentArrayBuffer = this._base64ToArrayBuffer(data.content); + const blob = new Blob([contentArrayBuffer], {type: data.mediaType}); + const url = URL.createObjectURL(blob); + cachedData.data = data; + cachedData.url = url; + } + return cachedData; + } + + _base64ToArrayBuffer(content) { + const binaryContent = window.atob(content); + const length = binaryContent.length; + const array = new Uint8Array(length); + for (let i = 0; i < length; ++i) { + array[i] = binaryContent.charCodeAt(i); + } + return array.buffer; + } +} diff --git a/ext/mixed/js/object-property-accessor.js b/ext/mixed/js/object-property-accessor.js index 349037b3..07b8df61 100644 --- a/ext/mixed/js/object-property-accessor.js +++ b/ext/mixed/js/object-property-accessor.js @@ -16,15 +16,27 @@ */ /** - * Class used to get and set generic properties of an object by using path strings. + * Class used to get and mutate generic properties of an object by using path strings. */ class ObjectPropertyAccessor { - constructor(target, setter=null) { + /** + * Create a new accessor for a specific object. + * @param target The object which the getter and mutation methods are applied to. + * @returns A new ObjectPropertyAccessor instance. + */ + constructor(target) { this._target = target; - this._setter = (typeof setter === 'function' ? setter : null); } - getProperty(pathArray, pathLength) { + /** + * Gets the value at the specified path. + * @param pathArray The path to the property on the target object. + * @param pathLength How many parts of the pathArray to use. + * This parameter is optional and defaults to the length of pathArray. + * @returns The value found at the path. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + get(pathArray, pathLength) { let target = this._target; const ii = typeof pathLength === 'number' ? Math.min(pathArray.length, pathLength) : pathArray.length; for (let i = 0; i < ii; ++i) { @@ -37,24 +49,89 @@ class ObjectPropertyAccessor { return target; } - setProperty(pathArray, value) { - if (pathArray.length === 0) { - throw new Error('Invalid path'); + /** + * Sets the value at the specified path. + * @param pathArray The path to the property on the target object. + * @param value The value to assign to the property. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + set(pathArray, value) { + const ii = pathArray.length - 1; + if (ii < 0) { throw new Error('Invalid path'); } + + const target = this.get(pathArray, ii); + const key = pathArray[ii]; + if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) { + throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); } - const target = this.getProperty(pathArray, pathArray.length - 1); - const key = pathArray[pathArray.length - 1]; + target[key] = value; + } + + /** + * Deletes the property of the target object at the specified path. + * @param pathArray The path to the property on the target object. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + delete(pathArray) { + const ii = pathArray.length - 1; + if (ii < 0) { throw new Error('Invalid path'); } + + const target = this.get(pathArray, ii); + const key = pathArray[ii]; if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) { throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); } - if (this._setter !== null) { - this._setter(target, key, value, pathArray); - } else { - target[key] = value; + if (Array.isArray(target)) { + throw new Error('Invalid type'); + } + + delete target[key]; + } + + /** + * Swaps two properties of an object or array. + * @param pathArray1 The path to the first property on the target object. + * @param pathArray2 The path to the second property on the target object. + * @throws An error is thrown if pathArray1 or pathArray2 is not valid for the target object, + * or if the swap cannot be performed. + */ + swap(pathArray1, pathArray2) { + const ii1 = pathArray1.length - 1; + if (ii1 < 0) { throw new Error('Invalid path 1'); } + const target1 = this.get(pathArray1, ii1); + const key1 = pathArray1[ii1]; + if (!ObjectPropertyAccessor.isValidPropertyType(target1, key1)) { throw new Error(`Invalid path 1: ${ObjectPropertyAccessor.getPathString(pathArray1)}`); } + + const ii2 = pathArray2.length - 1; + if (ii2 < 0) { throw new Error('Invalid path 2'); } + const target2 = this.get(pathArray2, ii2); + const key2 = pathArray2[ii2]; + if (!ObjectPropertyAccessor.isValidPropertyType(target2, key2)) { throw new Error(`Invalid path 2: ${ObjectPropertyAccessor.getPathString(pathArray2)}`); } + + const value1 = target1[key1]; + const value2 = target2[key2]; + + target1[key1] = value2; + try { + target2[key2] = value1; + } catch (e) { + // Revert + try { + target1[key1] = value1; + } catch (e2) { + // NOP + } + throw e; } } + /** + * Converts a path string to a path array. + * @param pathArray The path array to convert. + * @returns A string representation of pathArray. + */ static getPathString(pathArray) { const regexShort = /^[a-zA-Z_][a-zA-Z0-9_]*$/; let pathString = ''; @@ -86,6 +163,12 @@ class ObjectPropertyAccessor { return pathString; } + /** + * Converts a path array to a path string. For the most part, the format of this string + * matches Javascript's notation for property access. + * @param pathString The path string to convert. + * @returns An array representation of pathString. + */ static getPathArray(pathString) { const pathArray = []; let state = 'empty'; @@ -201,6 +284,14 @@ class ObjectPropertyAccessor { return pathArray; } + /** + * Checks whether an object or array has the specified property. + * @param object The object to test. + * @param property The property to check for existence. + * This value should be a string if the object is a non-array object. + * For arrays, it should be an integer. + * @returns true if the property exists, otherwise false. + */ static hasProperty(object, property) { switch (typeof property) { case 'string': @@ -222,6 +313,14 @@ class ObjectPropertyAccessor { } } + /** + * Checks whether a property is valid for the given object + * @param object The object to test. + * @param property The property to check for existence. + * @returns true if the property is correct for the given object type, otherwise false. + * For arrays, this means that the property should be a positive integer. + * For non-array objects, the property should be a string. + */ static isValidPropertyType(object, property) { switch (typeof property) { case 'string': diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index 0cd12cd7..b8688b08 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -21,47 +21,172 @@ * docRangeFromPoint */ -class TextScanner { - constructor(node, ignoreElements, ignorePoints) { - this.node = node; - this.ignoreElements = ignoreElements; - this.ignorePoints = ignorePoints; - - this.ignoreNodes = null; - - this.scanTimerPromise = null; - this.causeCurrent = null; - this.textSourceCurrent = null; - this.pendingLookup = false; - this.options = null; - - this.enabled = false; - this.eventListeners = new EventListenerCollection(); - - this.primaryTouchIdentifier = null; - this.preventNextContextMenu = false; - this.preventNextMouseDown = false; - this.preventNextClick = false; - this.preventScroll = false; +class TextScanner extends EventDispatcher { + constructor({node, ignoreElements, ignorePoint, search}) { + super(); + this._node = node; + this._ignoreElements = ignoreElements; + this._ignorePoint = ignorePoint; + this._search = search; + + this._ignoreNodes = null; + + this._causeCurrent = null; + this._scanTimerPromise = null; + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + this._pendingLookup = false; + this._options = null; + + this._enabled = false; + this._eventListeners = new EventListenerCollection(); + + this._primaryTouchIdentifier = null; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + this._preventScroll = false; + + this._canClearSelection = true; } - onMouseOver(e) { - if (this.ignoreElements().includes(e.target)) { - this.scanTimerClear(); + get canClearSelection() { + return this._canClearSelection; + } + + set canClearSelection(value) { + this._canClearSelection = value; + } + + get ignoreNodes() { + return this._ignoreNodes; + } + + set ignoreNodes(value) { + this._ignoreNodes = value; + } + + get causeCurrent() { + return this._causeCurrent; + } + + setEnabled(enabled) { + this._eventListeners.removeAllEventListeners(); + this._enabled = enabled; + if (this._enabled) { + this._hookEvents(); + } else { + this.clearSelection(true); + } + } + + setOptions(options) { + this._options = options; + } + + async searchAt(x, y, cause) { + try { + this._scanTimerClear(); + + if (this._pendingLookup) { + return; + } + + if (typeof this._ignorePoint === 'function' && await this._ignorePoint(x, y)) { + return; + } + + const textSource = docRangeFromPoint(x, y, this._options.scanning.deepDomScan); + try { + if (this._textSourceCurrent !== null && this._textSourceCurrent.equals(textSource)) { + return; + } + + this._pendingLookup = true; + const result = await this._search(textSource, cause); + if (result !== null) { + this._causeCurrent = cause; + this.setCurrentTextSource(textSource); + } + this._pendingLookup = false; + } finally { + if (textSource !== null) { + textSource.cleanup(); + } + } + } catch (e) { + yomichan.logError(e); + } + } + + getTextSourceContent(textSource, length) { + const clonedTextSource = textSource.clone(); + + clonedTextSource.setEndOffset(length); + + if (this._ignoreNodes !== null && clonedTextSource.range) { + length = clonedTextSource.text().length; + while (clonedTextSource.range && length > 0) { + const nodes = TextSourceRange.getNodesInRange(clonedTextSource.range); + if (!TextSourceRange.anyNodeMatchesSelector(nodes, this._ignoreNodes)) { + break; + } + --length; + clonedTextSource.setEndOffset(length); + } + } + + return clonedTextSource.text(); + } + + clearSelection(passive) { + if (!this._canClearSelection) { return; } + if (this._textSourceCurrent !== null) { + if (this._textSourceCurrentSelected) { + this._textSourceCurrent.deselect(); + } + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + } + this.trigger('clearSelection', {passive}); + } + + getCurrentTextSource() { + return this._textSourceCurrent; + } + + setCurrentTextSource(textSource) { + this._textSourceCurrent = textSource; + if (this._options.scanning.selectText) { + this._textSourceCurrent.select(); + this._textSourceCurrentSelected = true; + } else { + this._textSourceCurrentSelected = false; + } + } + + // Private + + _onMouseOver(e) { + if (this._ignoreElements().includes(e.target)) { + this._scanTimerClear(); } } - onMouseMove(e) { - this.scanTimerClear(); + _onMouseMove(e) { + this._scanTimerClear(); - if (this.pendingLookup || DOM.isMouseButtonDown(e, 'primary')) { + if (this._pendingLookup || DOM.isMouseButtonDown(e, 'primary')) { return; } - const scanningOptions = this.options.scanning; + const modifiers = DOM.getActiveModifiers(e); + this.trigger('activeModifiersChanged', {modifiers}); + + const scanningOptions = this._options.scanning; const scanningModifier = scanningOptions.modifier; if (!( - TextScanner.isScanningModifierPressed(scanningModifier, e) || + this._isScanningModifierPressed(scanningModifier, e) || (scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary')) )) { return; @@ -69,7 +194,7 @@ class TextScanner { const search = async () => { if (scanningModifier === 'none') { - if (!await this.scanTimerWait()) { + if (!await this._scanTimerWait()) { // Aborted return; } @@ -81,112 +206,110 @@ class TextScanner { search(); } - onMouseDown(e) { - if (this.preventNextMouseDown) { - this.preventNextMouseDown = false; - this.preventNextClick = true; + _onMouseDown(e) { + if (this._preventNextMouseDown) { + this._preventNextMouseDown = false; + this._preventNextClick = true; e.preventDefault(); e.stopPropagation(); return false; } if (DOM.isMouseButtonDown(e, 'primary')) { - this.scanTimerClear(); - this.onSearchClear(true); + this._scanTimerClear(); + this.clearSelection(false); } } - onMouseOut() { - this.scanTimerClear(); + _onMouseOut() { + this._scanTimerClear(); } - onClick(e) { - if (this.preventNextClick) { - this.preventNextClick = false; + _onClick(e) { + if (this._preventNextClick) { + this._preventNextClick = false; e.preventDefault(); e.stopPropagation(); return false; } } - onAuxClick() { - this.preventNextContextMenu = false; + _onAuxClick() { + this._preventNextContextMenu = false; } - onContextMenu(e) { - if (this.preventNextContextMenu) { - this.preventNextContextMenu = false; + _onContextMenu(e) { + if (this._preventNextContextMenu) { + this._preventNextContextMenu = false; e.preventDefault(); e.stopPropagation(); return false; } } - onTouchStart(e) { - if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) { + _onTouchStart(e) { + if (this._primaryTouchIdentifier !== null || e.changedTouches.length === 0) { return; } - this.preventScroll = false; - this.preventNextContextMenu = false; - this.preventNextMouseDown = false; - this.preventNextClick = false; + this._preventScroll = false; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; const primaryTouch = e.changedTouches[0]; if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, window.getSelection())) { return; } - this.primaryTouchIdentifier = primaryTouch.identifier; + this._primaryTouchIdentifier = primaryTouch.identifier; - if (this.pendingLookup) { + if (this._pendingLookup) { return; } - const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null; + const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null; this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart') .then(() => { if ( - this.textSourceCurrent === null || - this.textSourceCurrent.equals(textSourceCurrentPrevious) + this._textSourceCurrent === null || + this._textSourceCurrent.equals(textSourceCurrentPrevious) ) { return; } - this.preventScroll = true; - this.preventNextContextMenu = true; - this.preventNextMouseDown = true; + this._preventScroll = true; + this._preventNextContextMenu = true; + this._preventNextMouseDown = true; }); } - onTouchEnd(e) { + _onTouchEnd(e) { if ( - this.primaryTouchIdentifier === null || - TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier) === null + this._primaryTouchIdentifier === null || + this._getTouch(e.changedTouches, this._primaryTouchIdentifier) === null ) { return; } - this.primaryTouchIdentifier = null; - this.preventScroll = false; - this.preventNextClick = false; - // Don't revert context menu and mouse down prevention, - // since these events can occur after the touch has ended. - // this.preventNextContextMenu = false; - // this.preventNextMouseDown = false; + this._primaryTouchIdentifier = null; + this._preventScroll = false; + this._preventNextClick = false; + // Don't revert context menu and mouse down prevention, since these events can occur after the touch has ended. + // I.e. this._preventNextContextMenu and this._preventNextMouseDown should not be assigned to false. } - onTouchCancel(e) { - this.onTouchEnd(e); + _onTouchCancel(e) { + this._onTouchEnd(e); } - onTouchMove(e) { - if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) { + _onTouchMove(e) { + if (!this._preventScroll || !e.cancelable || this._primaryTouchIdentifier === null) { return; } - const primaryTouch = TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier); + const primaryTouch = this._getTouch(e.changedTouches, this._primaryTouchIdentifier); if (primaryTouch === null) { return; } @@ -196,171 +319,70 @@ class TextScanner { e.preventDefault(); // Disable scroll } - async onSearchSource(_textSource, _cause) { - throw new Error('Override me'); - } - - onError(error) { - logError(error, false); - } - - async scanTimerWait() { - const delay = this.options.scanning.delay; + async _scanTimerWait() { + const delay = this._options.scanning.delay; const promise = promiseTimeout(delay, true); - this.scanTimerPromise = promise; + this._scanTimerPromise = promise; try { return await promise; } finally { - if (this.scanTimerPromise === promise) { - this.scanTimerPromise = null; + if (this._scanTimerPromise === promise) { + this._scanTimerPromise = null; } } } - scanTimerClear() { - if (this.scanTimerPromise !== null) { - this.scanTimerPromise.resolve(false); - this.scanTimerPromise = null; + _scanTimerClear() { + if (this._scanTimerPromise !== null) { + this._scanTimerPromise.resolve(false); + this._scanTimerPromise = null; } } - setEnabled(enabled, canEnable) { - if (enabled && canEnable) { - if (!this.enabled) { - this.hookEvents(); - this.enabled = true; - } - } else { - if (this.enabled) { - this.eventListeners.removeAllEventListeners(); - this.enabled = false; - } - this.onSearchClear(false); - } - } - - hookEvents() { - let eventListenerInfos = this.getMouseEventListeners(); - if (this.options.scanning.touchInputEnabled) { - eventListenerInfos = eventListenerInfos.concat(this.getTouchEventListeners()); + _hookEvents() { + const eventListenerInfos = this._getMouseEventListeners(); + if (this._options.scanning.touchInputEnabled) { + eventListenerInfos.push(...this._getTouchEventListeners()); } for (const [node, type, listener, options] of eventListenerInfos) { - this.eventListeners.addEventListener(node, type, listener, options); + this._eventListeners.addEventListener(node, type, listener, options); } } - getMouseEventListeners() { + _getMouseEventListeners() { return [ - [this.node, 'mousedown', this.onMouseDown.bind(this)], - [this.node, 'mousemove', this.onMouseMove.bind(this)], - [this.node, 'mouseover', this.onMouseOver.bind(this)], - [this.node, 'mouseout', this.onMouseOut.bind(this)] + [this._node, 'mousedown', this._onMouseDown.bind(this)], + [this._node, 'mousemove', this._onMouseMove.bind(this)], + [this._node, 'mouseover', this._onMouseOver.bind(this)], + [this._node, 'mouseout', this._onMouseOut.bind(this)] ]; } - getTouchEventListeners() { + _getTouchEventListeners() { return [ - [this.node, 'click', this.onClick.bind(this)], - [this.node, 'auxclick', this.onAuxClick.bind(this)], - [this.node, 'touchstart', this.onTouchStart.bind(this)], - [this.node, 'touchend', this.onTouchEnd.bind(this)], - [this.node, 'touchcancel', this.onTouchCancel.bind(this)], - [this.node, 'touchmove', this.onTouchMove.bind(this), {passive: false}], - [this.node, 'contextmenu', this.onContextMenu.bind(this)] + [this._node, 'click', this._onClick.bind(this)], + [this._node, 'auxclick', this._onAuxClick.bind(this)], + [this._node, 'touchstart', this._onTouchStart.bind(this)], + [this._node, 'touchend', this._onTouchEnd.bind(this)], + [this._node, 'touchcancel', this._onTouchCancel.bind(this)], + [this._node, 'touchmove', this._onTouchMove.bind(this), {passive: false}], + [this._node, 'contextmenu', this._onContextMenu.bind(this)] ]; } - setOptions(options, canEnable=true) { - this.options = options; - this.setEnabled(this.options.general.enable, canEnable); - } - - async searchAt(x, y, cause) { - try { - this.scanTimerClear(); - - if (this.pendingLookup) { - return; - } - - for (const ignorePointFn of this.ignorePoints) { - if (await ignorePointFn(x, y)) { - return; - } - } - - const textSource = docRangeFromPoint(x, y, this.options.scanning.deepDomScan); - try { - if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) { - return; - } - - this.pendingLookup = true; - const result = await this.onSearchSource(textSource, cause); - if (result !== null) { - this.causeCurrent = cause; - this.textSourceCurrent = textSource; - if (this.options.scanning.selectText) { - textSource.select(); - } - } - this.pendingLookup = false; - } finally { - if (textSource !== null) { - textSource.cleanup(); - } - } - } catch (e) { - this.onError(e); - } - } - - setTextSourceScanLength(textSource, length) { - textSource.setEndOffset(length); - if (this.ignoreNodes === null || !textSource.range) { - return; - } - - length = textSource.text().length; - while (textSource.range && length > 0) { - const nodes = TextSourceRange.getNodesInRange(textSource.range); - if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) { - break; - } - --length; - textSource.setEndOffset(length); - } - } - - onSearchClear(_) { - if (this.textSourceCurrent !== null) { - if (this.options.scanning.selectText) { - this.textSourceCurrent.deselect(); - } - this.textSourceCurrent = null; - } - } - - getCurrentTextSource() { - return this.textSourceCurrent; - } - - setCurrentTextSource(textSource) { - return this.textSourceCurrent = textSource; - } - - static isScanningModifierPressed(scanningModifier, mouseEvent) { + _isScanningModifierPressed(scanningModifier, mouseEvent) { switch (scanningModifier) { case 'alt': return mouseEvent.altKey; case 'ctrl': return mouseEvent.ctrlKey; case 'shift': return mouseEvent.shiftKey; + case 'meta': return mouseEvent.metaKey; case 'none': return true; default: return false; } } - static getTouch(touchList, identifier) { + _getTouch(touchList, identifier) { for (const touch of touchList) { if (touch.identifier === identifier) { return touch; |