aboutsummaryrefslogtreecommitdiff
path: root/ext/mixed/js
diff options
context:
space:
mode:
authorAlex Yatskov <alex@foosoft.net>2020-06-27 19:04:19 -0700
committerAlex Yatskov <alex@foosoft.net>2020-06-27 19:04:19 -0700
commit88af95d20bfdbeb59d44bf0f0d46e772a329f839 (patch)
treed1dfa7268f274fed32061221c0f030e3647f9ae2 /ext/mixed/js
parent19197a9a5d6a1f54a179d894577dfac513b97401 (diff)
parent0a6c08d0f53090a4ad48663bc5846ddae5723d52 (diff)
Merge branch 'master' into testing
Diffstat (limited to 'ext/mixed/js')
-rw-r--r--ext/mixed/js/api.js594
-rw-r--r--ext/mixed/js/audio-system.js30
-rw-r--r--ext/mixed/js/comm.js282
-rw-r--r--ext/mixed/js/core.js57
-rw-r--r--ext/mixed/js/display-generator.js4
-rw-r--r--ext/mixed/js/display.js44
-rw-r--r--ext/mixed/js/dom-data-binder.js349
-rw-r--r--ext/mixed/js/dom.js56
-rw-r--r--ext/mixed/js/dynamic-loader.js53
-rw-r--r--ext/mixed/js/environment.js44
-rw-r--r--ext/mixed/js/media-loader.js4
-rw-r--r--ext/mixed/js/task-accumulator.js81
-rw-r--r--ext/mixed/js/text-scanner.js19
13 files changed, 1254 insertions, 363 deletions
diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js
index 0bc91759..5c17d50e 100644
--- a/ext/mixed/js/api.js
+++ b/ext/mixed/js/api.js
@@ -15,307 +15,341 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+/* global
+ * CrossFrameAPI
+ */
+
+const api = (() => {
+ class API {
+ constructor() {
+ this._forwardLogsToBackendEnabled = false;
+ this._crossFrame = new CrossFrameAPI();
+ }
+
+ get crossFrame() {
+ return this._crossFrame;
+ }
+
+ prepare() {
+ this._crossFrame.prepare();
+ }
+
+ forwardLogsToBackend() {
+ if (this._forwardLogsToBackendEnabled) { return; }
+ this._forwardLogsToBackendEnabled = true;
+
+ yomichan.on('log', async ({error, level, context}) => {
+ try {
+ await this.log(errorToJson(error), level, context);
+ } catch (e) {
+ // NOP
+ }
+ });
+ }
+
+ // Invoke functions
+
+ optionsSchemaGet() {
+ return this._invoke('optionsSchemaGet');
+ }
+
+ optionsGet(optionsContext) {
+ return this._invoke('optionsGet', {optionsContext});
+ }
+
+ optionsGetFull() {
+ return this._invoke('optionsGetFull');
+ }
+
+ optionsSave(source) {
+ return this._invoke('optionsSave', {source});
+ }
+
+ termsFind(text, details, optionsContext) {
+ return this._invoke('termsFind', {text, details, optionsContext});
+ }
+
+ textParse(text, optionsContext) {
+ return this._invoke('textParse', {text, optionsContext});
+ }
+
+ kanjiFind(text, optionsContext) {
+ return this._invoke('kanjiFind', {text, optionsContext});
+ }
+
+ definitionAdd(definition, mode, context, details, optionsContext) {
+ return this._invoke('definitionAdd', {definition, mode, context, details, optionsContext});
+ }
+
+ definitionsAddable(definitions, modes, context, optionsContext) {
+ return this._invoke('definitionsAddable', {definitions, modes, context, optionsContext});
+ }
+
+ noteView(noteId) {
+ return this._invoke('noteView', {noteId});
+ }
+
+ templateRender(template, data) {
+ return this._invoke('templateRender', {data, template});
+ }
+
+ audioGetUri(definition, source, details) {
+ return this._invoke('audioGetUri', {definition, source, details});
+ }
+
+ commandExec(command, params) {
+ return this._invoke('commandExec', {command, params});
+ }
+
+ screenshotGet(options) {
+ return this._invoke('screenshotGet', {options});
+ }
+
+ sendMessageToFrame(frameId, action, params) {
+ return this._invoke('sendMessageToFrame', {frameId, action, params});
+ }
+
+ broadcastTab(action, params) {
+ return this._invoke('broadcastTab', {action, params});
+ }
+
+ frameInformationGet() {
+ return this._invoke('frameInformationGet');
+ }
+
+ injectStylesheet(type, value) {
+ return this._invoke('injectStylesheet', {type, value});
+ }
+
+ getStylesheetContent(url) {
+ return this._invoke('getStylesheetContent', {url});
+ }
+
+ getEnvironmentInfo() {
+ return this._invoke('getEnvironmentInfo');
+ }
+
+ clipboardGet() {
+ return this._invoke('clipboardGet');
+ }
-function apiOptionsSchemaGet() {
- return _apiInvoke('optionsSchemaGet');
-}
+ getDisplayTemplatesHtml() {
+ return this._invoke('getDisplayTemplatesHtml');
+ }
+
+ getQueryParserTemplatesHtml() {
+ return this._invoke('getQueryParserTemplatesHtml');
+ }
+
+ getZoom() {
+ return this._invoke('getZoom');
+ }
+
+ getDefaultAnkiFieldTemplates() {
+ return this._invoke('getDefaultAnkiFieldTemplates');
+ }
+
+ getAnkiDeckNames() {
+ return this._invoke('getAnkiDeckNames');
+ }
+
+ getAnkiModelNames() {
+ return this._invoke('getAnkiModelNames');
+ }
+
+ getAnkiModelFieldNames(modelName) {
+ return this._invoke('getAnkiModelFieldNames', {modelName});
+ }
+
+ getDictionaryInfo() {
+ return this._invoke('getDictionaryInfo');
+ }
+
+ getDictionaryCounts(dictionaryNames, getTotal) {
+ return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal});
+ }
-function apiOptionsGet(optionsContext) {
- return _apiInvoke('optionsGet', {optionsContext});
-}
+ purgeDatabase() {
+ return this._invoke('purgeDatabase');
+ }
-function apiOptionsGetFull() {
- return _apiInvoke('optionsGetFull');
-}
+ getMedia(targets) {
+ return this._invoke('getMedia', {targets});
+ }
+
+ log(error, level, context) {
+ return this._invoke('log', {error, level, context});
+ }
-function apiOptionsSave(source) {
- return _apiInvoke('optionsSave', {source});
-}
+ logIndicatorClear() {
+ return this._invoke('logIndicatorClear');
+ }
-function apiTermsFind(text, details, optionsContext) {
- return _apiInvoke('termsFind', {text, details, optionsContext});
-}
+ modifySettings(targets, source) {
+ return this._invoke('modifySettings', {targets, source});
+ }
-function apiTextParse(text, optionsContext) {
- return _apiInvoke('textParse', {text, optionsContext});
-}
+ getSettings(targets) {
+ return this._invoke('getSettings', {targets});
+ }
-function apiKanjiFind(text, optionsContext) {
- return _apiInvoke('kanjiFind', {text, optionsContext});
-}
+ setAllSettings(value, source) {
+ return this._invoke('setAllSettings', {value, source});
+ }
-function apiDefinitionAdd(definition, mode, context, details, optionsContext) {
- return _apiInvoke('definitionAdd', {definition, mode, context, details, optionsContext});
-}
+ // Invoke functions with progress
-function apiDefinitionsAddable(definitions, modes, context, optionsContext) {
- return _apiInvoke('definitionsAddable', {definitions, modes, context, optionsContext});
-}
+ importDictionaryArchive(archiveContent, details, onProgress) {
+ return this._invokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress);
+ }
-function apiNoteView(noteId) {
- return _apiInvoke('noteView', {noteId});
-}
+ deleteDictionary(dictionaryName, onProgress) {
+ return this._invokeWithProgress('deleteDictionary', {dictionaryName}, onProgress);
+ }
-function apiTemplateRender(template, data) {
- return _apiInvoke('templateRender', {data, template});
-}
+ // Utilities
-function apiAudioGetUri(definition, source, details) {
- return _apiInvoke('audioGetUri', {definition, source, details});
-}
+ _createActionPort(timeout=5000) {
+ return new Promise((resolve, reject) => {
+ let timer = null;
+ let portNameResolve;
+ let portNameReject;
+ const portNamePromise = new Promise((resolve2, reject2) => {
+ portNameResolve = resolve2;
+ portNameReject = reject2;
+ });
-function apiCommandExec(command, params) {
- return _apiInvoke('commandExec', {command, params});
-}
-
-function apiScreenshotGet(options) {
- return _apiInvoke('screenshotGet', {options});
-}
-
-function apiSendMessageToFrame(frameId, action, params) {
- return _apiInvoke('sendMessageToFrame', {frameId, action, params});
-}
-
-function apiBroadcastTab(action, params) {
- return _apiInvoke('broadcastTab', {action, params});
-}
-
-function apiFrameInformationGet() {
- return _apiInvoke('frameInformationGet');
-}
-
-function apiInjectStylesheet(type, value) {
- return _apiInvoke('injectStylesheet', {type, value});
-}
-
-function apiGetEnvironmentInfo() {
- return _apiInvoke('getEnvironmentInfo');
-}
-
-function apiClipboardGet() {
- return _apiInvoke('clipboardGet');
-}
-
-function apiGetDisplayTemplatesHtml() {
- return _apiInvoke('getDisplayTemplatesHtml');
-}
-
-function apiGetQueryParserTemplatesHtml() {
- return _apiInvoke('getQueryParserTemplatesHtml');
-}
-
-function apiGetZoom() {
- return _apiInvoke('getZoom');
-}
-
-function apiGetDefaultAnkiFieldTemplates() {
- return _apiInvoke('getDefaultAnkiFieldTemplates');
-}
-
-function apiGetAnkiDeckNames() {
- return _apiInvoke('getAnkiDeckNames');
-}
-
-function apiGetAnkiModelNames() {
- return _apiInvoke('getAnkiModelNames');
-}
-
-function apiGetAnkiModelFieldNames(modelName) {
- return _apiInvoke('getAnkiModelFieldNames', {modelName});
-}
-
-function apiGetDictionaryInfo() {
- return _apiInvoke('getDictionaryInfo');
-}
-
-function apiGetDictionaryCounts(dictionaryNames, getTotal) {
- return _apiInvoke('getDictionaryCounts', {dictionaryNames, getTotal});
-}
-
-function apiPurgeDatabase() {
- return _apiInvoke('purgeDatabase');
-}
-
-function apiGetMedia(targets) {
- return _apiInvoke('getMedia', {targets});
-}
-
-function apiLog(error, level, context) {
- return _apiInvoke('log', {error, level, context});
-}
-
-function apiLogIndicatorClear() {
- return _apiInvoke('logIndicatorClear');
-}
-
-function apiImportDictionaryArchive(archiveContent, details, onProgress) {
- return _apiInvokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress);
-}
-
-function apiDeleteDictionary(dictionaryName, onProgress) {
- return _apiInvokeWithProgress('deleteDictionary', {dictionaryName}, onProgress);
-}
-
-function apiModifySettings(targets, source) {
- return _apiInvoke('modifySettings', {targets, source});
-}
-
-function _apiCreateActionPort(timeout=5000) {
- return new Promise((resolve, reject) => {
- let timer = null;
- let portNameResolve;
- let portNameReject;
- const portNamePromise = new Promise((resolve2, reject2) => {
- portNameResolve = resolve2;
- portNameReject = reject2;
- });
-
- const onConnect = async (port) => {
- try {
- const portName = await portNamePromise;
- if (port.name !== portName || timer === null) { return; }
- } catch (e) {
- return;
- }
-
- clearTimeout(timer);
- timer = null;
-
- chrome.runtime.onConnect.removeListener(onConnect);
- resolve(port);
- };
-
- const onError = (e) => {
- if (timer !== null) {
- clearTimeout(timer);
- timer = null;
- }
- chrome.runtime.onConnect.removeListener(onConnect);
- portNameReject(e);
- reject(e);
- };
-
- timer = setTimeout(() => onError(new Error('Timeout')), timeout);
-
- chrome.runtime.onConnect.addListener(onConnect);
- _apiInvoke('createActionPort').then(portNameResolve, onError);
- });
-}
-
-function _apiInvokeWithProgress(action, params, onProgress, timeout=5000) {
- return new Promise((resolve, reject) => {
- let timer = null;
- let port = null;
-
- if (typeof onProgress !== 'function') {
- onProgress = () => {};
- }
-
- const onMessage = (message) => {
- switch (message.type) {
- case 'ack':
+ const onConnect = async (port) => {
+ try {
+ const portName = await portNamePromise;
+ if (port.name !== portName || timer === null) { return; }
+ } catch (e) {
+ return;
+ }
+
+ clearTimeout(timer);
+ timer = null;
+
+ chrome.runtime.onConnect.removeListener(onConnect);
+ resolve(port);
+ };
+
+ const onError = (e) => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
- break;
- case 'progress':
- try {
- onProgress(...message.data);
- } catch (e) {
- // NOP
+ chrome.runtime.onConnect.removeListener(onConnect);
+ portNameReject(e);
+ reject(e);
+ };
+
+ timer = setTimeout(() => onError(new Error('Timeout')), timeout);
+
+ chrome.runtime.onConnect.addListener(onConnect);
+ this._invoke('createActionPort').then(portNameResolve, onError);
+ });
+ }
+
+ _invokeWithProgress(action, params, onProgress, timeout=5000) {
+ return new Promise((resolve, reject) => {
+ let port = null;
+
+ if (typeof onProgress !== 'function') {
+ onProgress = () => {};
+ }
+
+ const onMessage = (message) => {
+ switch (message.type) {
+ case 'progress':
+ try {
+ onProgress(...message.data);
+ } catch (e) {
+ // NOP
+ }
+ break;
+ case 'complete':
+ cleanup();
+ resolve(message.data);
+ break;
+ case 'error':
+ cleanup();
+ reject(jsonToError(message.data));
+ break;
}
- break;
- case 'complete':
- cleanup();
- resolve(message.data);
- break;
- case 'error':
+ };
+
+ const onDisconnect = () => {
cleanup();
- reject(jsonToError(message.data));
- break;
- }
- };
-
- const onDisconnect = () => {
- cleanup();
- reject(new Error('Disconnected'));
- };
-
- const cleanup = () => {
- if (timer !== null) {
- clearTimeout(timer);
- timer = null;
- }
- if (port !== null) {
- port.onMessage.removeListener(onMessage);
- port.onDisconnect.removeListener(onDisconnect);
- port.disconnect();
- port = null;
- }
- onProgress = null;
- };
-
- timer = setTimeout(() => {
- cleanup();
- reject(new Error('Timeout'));
- }, timeout);
-
- (async () => {
- try {
- port = await _apiCreateActionPort(timeout);
- port.onMessage.addListener(onMessage);
- port.onDisconnect.addListener(onDisconnect);
- port.postMessage({action, params});
- } catch (e) {
- cleanup();
- reject(e);
- } finally {
- action = null;
- params = null;
- }
- })();
- });
-}
-
-function _apiInvoke(action, params={}) {
- const data = {action, params};
- return new Promise((resolve, reject) => {
- try {
- chrome.runtime.sendMessage(data, (response) => {
- _apiCheckLastError(chrome.runtime.lastError);
- if (response !== null && typeof response === 'object') {
- if (typeof response.error !== 'undefined') {
- reject(jsonToError(response.error));
- } else {
- resolve(response.result);
+ reject(new Error('Disconnected'));
+ };
+
+ const cleanup = () => {
+ if (port !== null) {
+ port.onMessage.removeListener(onMessage);
+ port.onDisconnect.removeListener(onDisconnect);
+ port.disconnect();
+ port = null;
+ }
+ onProgress = null;
+ };
+
+ (async () => {
+ try {
+ port = await this._createActionPort(timeout);
+ port.onMessage.addListener(onMessage);
+ port.onDisconnect.addListener(onDisconnect);
+
+ // Chrome has a maximum message size that can be sent, so longer messages must be fragmented.
+ const messageString = JSON.stringify({action, params});
+ const fragmentSize = 1e7; // 10 MB
+ for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) {
+ const data = messageString.substring(i, i + fragmentSize);
+ port.postMessage({action: 'fragment', data});
+ }
+ port.postMessage({action: 'invoke'});
+ } catch (e) {
+ cleanup();
+ reject(e);
+ } finally {
+ action = null;
+ params = null;
}
- } else {
- const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`;
- reject(new Error(`${message} (${JSON.stringify(data)})`));
+ })();
+ });
+ }
+
+ _invoke(action, params={}) {
+ const data = {action, params};
+ return new Promise((resolve, reject) => {
+ try {
+ chrome.runtime.sendMessage(data, (response) => {
+ this._checkLastError(chrome.runtime.lastError);
+ if (response !== null && typeof response === 'object') {
+ if (typeof response.error !== 'undefined') {
+ reject(jsonToError(response.error));
+ } else {
+ resolve(response.result);
+ }
+ } else {
+ const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`;
+ reject(new Error(`${message} (${JSON.stringify(data)})`));
+ }
+ });
+ } catch (e) {
+ reject(e);
+ yomichan.triggerOrphaned(e);
}
});
- } catch (e) {
- reject(e);
- yomichan.triggerOrphaned(e);
- }
- });
-}
-
-function _apiCheckLastError() {
- // NOP
-}
-
-let _apiForwardLogsToBackendEnabled = false;
-function apiForwardLogsToBackend() {
- if (_apiForwardLogsToBackendEnabled) { return; }
- _apiForwardLogsToBackendEnabled = true;
-
- yomichan.on('log', async ({error, level, context}) => {
- try {
- await apiLog(errorToJson(error), level, context);
- } catch (e) {
+ }
+
+ _checkLastError() {
// NOP
}
- });
-}
+ }
+
+ // eslint-disable-next-line no-shadow
+ const api = new API();
+ api.prepare();
+ return api;
+})();
diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js
index fdfb0b10..c590b909 100644
--- a/ext/mixed/js/audio-system.js
+++ b/ext/mixed/js/audio-system.js
@@ -169,22 +169,22 @@ class AudioSystem {
});
}
- _createAudioBinaryFromUrl(url) {
- return new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.responseType = 'arraybuffer';
- xhr.addEventListener('load', async () => {
- const arrayBuffer = xhr.response;
- if (!await this._isAudioBinaryValid(arrayBuffer)) {
- reject(new Error('Could not retrieve audio'));
- } else {
- resolve(arrayBuffer);
- }
- });
- xhr.addEventListener('error', () => reject(new Error('Failed to connect')));
- xhr.open('GET', url);
- xhr.send();
+ async _createAudioBinaryFromUrl(url) {
+ const response = await fetch(url, {
+ method: 'GET',
+ mode: 'no-cors',
+ cache: 'default',
+ credentials: 'omit',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer'
});
+ const arrayBuffer = await response.arrayBuffer();
+
+ if (!await this._isAudioBinaryValid(arrayBuffer)) {
+ throw new Error('Could not retrieve audio');
+ }
+
+ return arrayBuffer;
}
_isAudioValid(audio) {
diff --git a/ext/mixed/js/comm.js b/ext/mixed/js/comm.js
new file mode 100644
index 00000000..7787616e
--- /dev/null
+++ b/ext/mixed/js/comm.js
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class CrossFrameAPIPort extends EventDispatcher {
+ constructor(otherFrameId, port, messageHandlers) {
+ super();
+ this._otherFrameId = otherFrameId;
+ this._port = port;
+ this._messageHandlers = messageHandlers;
+ this._activeInvocations = new Map();
+ this._invocationId = 0;
+ this._eventListeners = new EventListenerCollection();
+ }
+
+ get otherFrameId() {
+ return this._otherFrameId;
+ }
+
+ prepare() {
+ this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this));
+ this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this));
+ }
+
+ invoke(action, params, ackTimeout, responseTimeout) {
+ return new Promise((resolve, reject) => {
+ if (this._port === null) {
+ reject(new Error('Port is disconnected'));
+ return;
+ }
+
+ const id = this._invocationId++;
+ const invocation = {id, resolve, reject, responseTimeout, ack: false, timer: null};
+ this._activeInvocations.set(id, invocation);
+
+ if (ackTimeout !== null) {
+ try {
+ invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (ack)')), ackTimeout);
+ } catch (e) {
+ this._onError(id, new Error('Failed to set timeout'));
+ return;
+ }
+ }
+
+ try {
+ this._port.postMessage({type: 'invoke', id, data: {action, params}});
+ } catch (e) {
+ this._onError(id, e);
+ }
+ });
+ }
+
+ disconnect() {
+ this._onDisconnect();
+ }
+
+ // Private
+
+ _onDisconnect() {
+ if (this._port === null) { return; }
+ this._eventListeners.removeAllEventListeners();
+ this._port = null;
+ for (const id of this._activeInvocations.keys()) {
+ this._onError(id, new Error('Disconnected'));
+ }
+ this.trigger('disconnect', this);
+ }
+
+ _onMessage({type, id, data}) {
+ switch (type) {
+ case 'invoke':
+ this._onInvoke(id, data);
+ break;
+ case 'ack':
+ this._onAck(id);
+ break;
+ case 'result':
+ this._onResult(id, data);
+ break;
+ }
+ }
+
+ // Response handlers
+
+ _onAck(id) {
+ const invocation = this._activeInvocations.get(id);
+ if (typeof invocation === 'undefined') {
+ yomichan.logWarning(new Error(`Request ${id} not found for ack`));
+ return;
+ }
+
+ if (invocation.ack) {
+ this._onError(id, new Error(`Request ${id} already ack'd`));
+ return;
+ }
+
+ invocation.ack = true;
+
+ if (invocation.timer !== null) {
+ clearTimeout(invocation.timer);
+ invocation.timer = null;
+ }
+
+ const responseTimeout = invocation.responseTimeout;
+ if (responseTimeout !== null) {
+ try {
+ invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (response)')), responseTimeout);
+ } catch (e) {
+ this._onError(id, new Error('Failed to set timeout'));
+ }
+ }
+ }
+
+ _onResult(id, data) {
+ const invocation = this._activeInvocations.get(id);
+ if (typeof invocation === 'undefined') {
+ yomichan.logWarning(new Error(`Request ${id} not found`));
+ return;
+ }
+
+ if (!invocation.ack) {
+ this._onError(id, new Error(`Request ${id} not ack'd`));
+ return;
+ }
+
+ this._activeInvocations.delete(id);
+
+ if (invocation.timer !== null) {
+ clearTimeout(invocation.timer);
+ invocation.timer = null;
+ }
+
+ const error = data.error;
+ if (typeof error !== 'undefined') {
+ invocation.reject(jsonToError(error));
+ } else {
+ invocation.resolve(data.result);
+ }
+ }
+
+ _onError(id, error) {
+ const invocation = this._activeInvocations.get(id);
+ if (typeof invocation === 'undefined') { return; }
+
+ this._activeInvocations.delete(id);
+ if (invocation.timer !== null) {
+ clearTimeout(invocation.timer);
+ invocation.timer = null;
+ }
+ invocation.reject(error);
+ }
+
+ // Invocation
+
+ _onInvoke(id, {action, params}) {
+ const messageHandler = this._messageHandlers.get(action);
+ if (typeof messageHandler === 'undefined') {
+ this._sendError(id, new Error(`Unknown action: ${action}`));
+ return;
+ }
+
+ const {handler, async} = messageHandler;
+
+ this._sendAck(id);
+ if (async) {
+ this._invokeHandlerAsync(id, handler, params);
+ } else {
+ this._invokeHandler(id, handler, params);
+ }
+ }
+
+ _invokeHandler(id, handler, params) {
+ try {
+ const result = handler(params);
+ this._sendResult(id, result);
+ } catch (error) {
+ this._sendError(id, error);
+ }
+ }
+
+ async _invokeHandlerAsync(id, handler, params) {
+ try {
+ const result = await handler(params);
+ this._sendResult(id, result);
+ } catch (error) {
+ this._sendError(id, error);
+ }
+ }
+
+ _sendResponse(data) {
+ if (this._port === null) { return; }
+ try {
+ this._port.postMessage(data);
+ } catch (e) {
+ // NOP
+ }
+ }
+
+ _sendAck(id) {
+ this._sendResponse({type: 'ack', id});
+ }
+
+ _sendResult(id, result) {
+ this._sendResponse({type: 'result', id, data: {result}});
+ }
+
+ _sendError(id, error) {
+ this._sendResponse({type: 'result', id, data: {error: errorToJson(error)}});
+ }
+}
+
+class CrossFrameAPI {
+ constructor() {
+ this._ackTimeout = 3000; // 3 seconds
+ this._responseTimeout = 10000; // 10 seconds
+ this._commPorts = new Map();
+ this._messageHandlers = new Map();
+ this._onDisconnectBind = this._onDisconnect.bind(this);
+ }
+
+ prepare() {
+ chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
+ }
+
+ async invoke(targetFrameId, action, params={}) {
+ const commPort = this._getOrCreateCommPort(targetFrameId);
+ return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout);
+ }
+
+ registerHandlers(messageHandlers) {
+ for (const [key, value] of messageHandlers) {
+ if (this._messageHandlers.has(key)) {
+ throw new Error(`Handler ${key} is already registered`);
+ }
+ this._messageHandlers.set(key, value);
+ }
+ }
+
+ _onConnect(port) {
+ const match = /^cross-frame-communication-port-(\d+)$/.exec(`${port.name}`);
+ if (match === null) { return; }
+
+ const otherFrameId = parseInt(match[1], 10);
+ this._setupCommPort(otherFrameId, port);
+ }
+
+ _onDisconnect(commPort) {
+ commPort.off('disconnect', this._onDisconnectBind);
+ this._commPorts.delete(commPort.otherFrameId);
+ }
+
+ _getOrCreateCommPort(otherFrameId) {
+ const commPort = this._commPorts.get(otherFrameId);
+ return (typeof commPort !== 'undefined' ? commPort : this._createCommPort(otherFrameId));
+ }
+
+ _createCommPort(otherFrameId) {
+ const port = chrome.runtime.connect(null, {name: `background-cross-frame-communication-port-${otherFrameId}`});
+ return this._setupCommPort(otherFrameId, port);
+ }
+
+ _setupCommPort(otherFrameId, port) {
+ const commPort = new CrossFrameAPIPort(otherFrameId, port, this._messageHandlers);
+ this._commPorts.set(otherFrameId, commPort);
+ commPort.prepare();
+ commPort.on('disconnect', this._onDisconnectBind);
+ return commPort;
+ }
+}
diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js
index 589425f2..bf877e72 100644
--- a/ext/mixed/js/core.js
+++ b/ext/mixed/js/core.js
@@ -164,7 +164,10 @@ function getSetDifference(set1, set2) {
function promiseTimeout(delay, resolveValue) {
if (delay <= 0) {
- return Promise.resolve(resolveValue);
+ const promise = Promise.resolve(resolveValue);
+ promise.resolve = () => {}; // NOP
+ promise.reject = () => {}; // NOP
+ return promise;
}
let timer = null;
@@ -174,7 +177,7 @@ function promiseTimeout(delay, resolveValue) {
const complete = (callback, value) => {
if (callback === null) { return; }
if (timer !== null) {
- window.clearTimeout(timer);
+ clearTimeout(timer);
timer = null;
}
promiseResolve = null;
@@ -189,7 +192,7 @@ function promiseTimeout(delay, resolveValue) {
promiseResolve = resolve2;
promiseReject = reject2;
});
- timer = window.setTimeout(() => {
+ timer = setTimeout(() => {
timer = null;
resolve(resolveValue);
}, delay);
@@ -255,15 +258,37 @@ class EventListenerCollection {
return this._eventListeners.length;
}
- addEventListener(node, type, listener, options) {
- node.addEventListener(type, listener, options);
- this._eventListeners.push([node, type, listener, options]);
+ addEventListener(object, ...args) {
+ object.addEventListener(...args);
+ this._eventListeners.push(['removeEventListener', object, ...args]);
+ }
+
+ addListener(object, ...args) {
+ object.addListener(...args);
+ this._eventListeners.push(['removeListener', object, ...args]);
+ }
+
+ on(object, ...args) {
+ object.on(...args);
+ this._eventListeners.push(['off', object, ...args]);
}
removeAllEventListeners() {
if (this._eventListeners.length === 0) { return; }
- for (const [node, type, listener, options] of this._eventListeners) {
- node.removeEventListener(type, listener, options);
+ for (const [removeFunctionName, object, ...args] of this._eventListeners) {
+ switch (removeFunctionName) {
+ case 'removeEventListener':
+ object.removeEventListener(...args);
+ break;
+ case 'removeListener':
+ object.removeListener(...args);
+ break;
+ case 'off':
+ object.off(...args);
+ break;
+ default:
+ throw new Error(`Unknown remove function: ${removeFunctionName}`);
+ }
}
this._eventListeners = [];
}
@@ -306,7 +331,7 @@ const yomichan = (() => {
generateId(length) {
const array = new Uint8Array(length);
- window.crypto.getRandomValues(array);
+ crypto.getRandomValues(array);
let id = '';
for (const value of array) {
id += value.toString(16).padStart(2, '0');
@@ -339,7 +364,7 @@ const yomichan = (() => {
const runtimeMessageCallback = ({action, params}, sender, sendResponse) => {
let timeoutId = null;
if (timeout !== null) {
- timeoutId = window.setTimeout(() => {
+ timeoutId = setTimeout(() => {
timeoutId = null;
eventHandler.removeListener(runtimeMessageCallback);
reject(new Error(`Listener timed out in ${timeout} ms`));
@@ -348,7 +373,7 @@ const yomichan = (() => {
const cleanupResolve = (value) => {
if (timeoutId !== null) {
- window.clearTimeout(timeoutId);
+ clearTimeout(timeoutId);
timeoutId = null;
}
eventHandler.removeListener(runtimeMessageCallback);
@@ -428,10 +453,12 @@ const yomichan = (() => {
// Private
+ _getUrl() {
+ return (typeof window === 'object' && window !== null ? window.location.href : '');
+ }
+
_getLogContext() {
- return {
- url: window.location.href
- };
+ return {url: this._getUrl()};
}
_onMessage({action, params}, sender, callback) {
@@ -444,7 +471,7 @@ const yomichan = (() => {
}
_onMessageGetUrl() {
- return {url: window.location.href};
+ return {url: this._getUrl()};
}
_onMessageOptionsUpdated({source}) {
diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js
index a2b2b139..3f3a155e 100644
--- a/ext/mixed/js/display-generator.js
+++ b/ext/mixed/js/display-generator.js
@@ -17,7 +17,7 @@
/* global
* TemplateHandler
- * apiGetDisplayTemplatesHtml
+ * api
* jp
*/
@@ -29,7 +29,7 @@ class DisplayGenerator {
}
async prepare() {
- const html = await apiGetDisplayTemplatesHtml();
+ const html = await api.getDisplayTemplatesHtml();
this._templateHandler = new TemplateHandler(html);
}
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index 2e59b4ff..1d699706 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -22,15 +22,7 @@
* DisplayGenerator
* MediaLoader
* WindowScroll
- * apiAudioGetUri
- * apiBroadcastTab
- * apiDefinitionAdd
- * apiDefinitionsAddable
- * apiKanjiFind
- * apiNoteView
- * apiOptionsGet
- * apiScreenshotGet
- * apiTermsFind
+ * api
* docRangeFromPoint
* docSentenceExtract
*/
@@ -49,7 +41,7 @@ class Display {
this.audioSystem = new AudioSystem({
audioUriBuilder: {
getUri: async (definition, source, details) => {
- return await apiAudioGetUri(definition, source, details);
+ return await api.audioGetUri(definition, source, details);
}
},
useCache: true
@@ -212,7 +204,7 @@ class Display {
url: this.context.get('url')
};
- const definitions = await apiKanjiFind(link.textContent, this.getOptionsContext());
+ const definitions = await api.kanjiFind(link.textContent, this.getOptionsContext());
this.setContent('kanji', {definitions, context});
} catch (error) {
this.onError(error);
@@ -244,7 +236,9 @@ class Display {
const {textSource, definitions} = termLookupResults;
const scannedElement = e.target;
- const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
+ const sentenceExtent = this.options.anki.sentenceExt;
+ const layoutAwareScan = this.options.scanning.layoutAwareScan;
+ const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan);
this.context.update({
index: this.entryIndexFind(scannedElement),
@@ -281,21 +275,22 @@ class Display {
try {
e.preventDefault();
- const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options.scanning.deepDomScan);
+ const {length: scanLength, deepDomScan: deepScan, layoutAwareScan} = this.options.scanning;
+ const textSource = docRangeFromPoint(e.clientX, e.clientY, deepScan);
if (textSource === null) {
return false;
}
let definitions, length;
try {
- textSource.setEndOffset(this.options.scanning.length);
+ textSource.setEndOffset(scanLength, layoutAwareScan);
- ({definitions, length} = await apiTermsFind(textSource.text(), {}, this.getOptionsContext()));
+ ({definitions, length} = await api.termsFind(textSource.text(), {}, this.getOptionsContext()));
if (definitions.length === 0) {
return false;
}
- textSource.setEndOffset(length);
+ textSource.setEndOffset(length, layoutAwareScan);
} finally {
textSource.cleanup();
}
@@ -334,7 +329,7 @@ class Display {
onNoteView(e) {
e.preventDefault();
const link = e.currentTarget;
- apiNoteView(link.dataset.noteId);
+ api.noteView(link.dataset.noteId);
}
onKeyDown(e) {
@@ -379,7 +374,7 @@ class Display {
}
async updateOptions() {
- this.options = await apiOptionsGet(this.getOptionsContext());
+ this.options = await api.optionsGet(this.getOptionsContext());
this.updateDocumentOptions(this.options);
this.updateTheme(this.options.general.popupTheme);
this.setCustomCss(this.options.general.customPopupCss);
@@ -746,7 +741,7 @@ class Display {
noteTryView() {
const button = this.viewerButtonFind(this.index);
if (button !== null && !button.classList.contains('disabled')) {
- apiNoteView(button.dataset.noteId);
+ api.noteView(button.dataset.noteId);
}
}
@@ -763,7 +758,7 @@ class Display {
}
const context = await this._getNoteContext();
- const noteId = await apiDefinitionAdd(definition, mode, context, details, this.getOptionsContext());
+ const noteId = await api.definitionAdd(definition, mode, context, details, this.getOptionsContext());
if (noteId) {
const index = this.definitions.indexOf(definition);
const adderButton = this.adderButtonFind(index, mode);
@@ -815,9 +810,10 @@ class Display {
this._stopPlayingAudio();
+ const volume = Math.max(0.0, Math.min(1.0, this.options.audio.volume / 100.0));
this.audioPlaying = audio;
audio.currentTime = 0;
- audio.volume = this.options.audio.volume / 100.0;
+ audio.volume = Number.isFinite(volume) ? volume : 1.0;
const playPromise = audio.play();
if (typeof playPromise !== 'undefined') {
try {
@@ -857,7 +853,7 @@ class Display {
await promiseTimeout(1); // Wait for popup to be hidden.
const {format, quality} = this.options.anki.screenshot;
- const dataUrl = await apiScreenshotGet({format, quality});
+ const dataUrl = await api.screenshotGet({format, quality});
if (!dataUrl || dataUrl.error) { return; }
return {dataUrl, format};
@@ -871,7 +867,7 @@ class Display {
}
setPopupVisibleOverride(visible) {
- return apiBroadcastTab('popupSetVisibleOverride', {visible});
+ return api.broadcastTab('popupSetVisibleOverride', {visible});
}
setSpinnerVisible(visible) {
@@ -933,7 +929,7 @@ class Display {
async getDefinitionsAddable(definitions, modes) {
try {
const context = await this._getNoteContext();
- return await apiDefinitionsAddable(definitions, modes, context, this.getOptionsContext());
+ return await api.definitionsAddable(definitions, modes, context, this.getOptionsContext());
} catch (e) {
return [];
}
diff --git a/ext/mixed/js/dom-data-binder.js b/ext/mixed/js/dom-data-binder.js
new file mode 100644
index 00000000..d46e8087
--- /dev/null
+++ b/ext/mixed/js/dom-data-binder.js
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * TaskAccumulator
+ */
+
+class DOMDataBinder {
+ constructor({selector, ignoreSelectors=[], createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) {
+ this._selector = selector;
+ this._ignoreSelectors = ignoreSelectors;
+ this._createElementMetadata = createElementMetadata;
+ this._compareElementMetadata = compareElementMetadata;
+ this._getValues = getValues;
+ this._setValues = setValues;
+ this._onError = onError;
+ this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this));
+ this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this));
+ this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
+ this._observingElement = null;
+ this._elementMap = new Map(); // Map([element => observer]...)
+ this._elementAncestorMap = new Map(); // Map([element => Set([observer]...))
+ }
+
+ observe(element) {
+ if (this._isObserving) { return; }
+
+ this._observingElement = element;
+ this._mutationObserver.observe(element, {
+ attributes: true,
+ attributeOldValue: true,
+ childList: true,
+ subtree: true
+ });
+ this._onMutation([{
+ type: 'childList',
+ target: element.parentNode,
+ addedNodes: [element],
+ removedNodes: []
+ }]);
+ }
+
+ disconnect() {
+ if (!this._isObserving) { return; }
+
+ this._mutationObserver.disconnect();
+ this._observingElement = null;
+
+ for (const observer of this._elementMap.values()) {
+ this._removeObserver(observer);
+ }
+ }
+
+ async refresh() {
+ await this._updateTasks.enqueue(null, {all: true});
+ }
+
+ // Private
+
+ _onMutation(mutationList) {
+ for (const mutation of mutationList) {
+ switch (mutation.type) {
+ case 'childList':
+ this._onChildListMutation(mutation);
+ break;
+ case 'attributes':
+ this._onAttributeMutation(mutation);
+ break;
+ }
+ }
+ }
+
+ _onChildListMutation({addedNodes, removedNodes, target}) {
+ const selector = this._selector;
+ const ELEMENT_NODE = Node.ELEMENT_NODE;
+
+ for (const node of removedNodes) {
+ const observers = this._elementAncestorMap.get(node);
+ if (typeof observers === 'undefined') { continue; }
+ for (const observer of observers) {
+ this._removeObserver(observer);
+ }
+ }
+
+ for (const node of addedNodes) {
+ if (node.nodeType !== ELEMENT_NODE) { continue; }
+ if (node.matches(selector)) {
+ this._createObserver(node);
+ }
+ for (const childNode of node.querySelectorAll(selector)) {
+ this._createObserver(childNode);
+ }
+ }
+
+ if (addedNodes.length !== 0 || addedNodes.length !== 0) {
+ const observer = this._elementMap.get(target);
+ if (typeof observer !== 'undefined' && observer.hasValue) {
+ this._setElementValue(observer.element, observer.value);
+ }
+ }
+ }
+
+ _onAttributeMutation({target}) {
+ const selector = this._selector;
+ const observers = this._elementAncestorMap.get(target);
+ if (typeof observers !== 'undefined') {
+ for (const observer of observers) {
+ const element = observer.element;
+ if (
+ !element.matches(selector) ||
+ this._shouldIgnoreElement(element) ||
+ this._isObserverStale(observer)
+ ) {
+ this._removeObserver(observer);
+ }
+ }
+ }
+
+ if (target.matches(selector)) {
+ this._createObserver(target);
+ }
+ }
+
+ async _onBulkUpdate(tasks) {
+ let all = false;
+ const targets = [];
+ for (const [observer, task] of tasks) {
+ if (observer === null) {
+ if (task.data.all) {
+ all = true;
+ break;
+ }
+ } else {
+ targets.push([observer, task]);
+ }
+ }
+ if (all) {
+ targets.length = 0;
+ for (const observer of this._elementMap.values()) {
+ targets.push([observer, null]);
+ }
+ }
+
+ const args = targets.map(([observer]) => ({
+ element: observer.element,
+ metadata: observer.metadata
+ }));
+ const responses = await this._getValues(args);
+ this._applyValues(targets, responses, true);
+ }
+
+ async _onBulkAssign(tasks) {
+ const targets = tasks;
+ const args = targets.map(([observer, task]) => ({
+ element: observer.element,
+ metadata: observer.metadata,
+ value: task.data.value
+ }));
+ const responses = await this._setValues(args);
+ this._applyValues(targets, responses, false);
+ }
+
+ _onElementChange(observer) {
+ const value = this._getElementValue(observer.element);
+ observer.value = value;
+ observer.hasValue = true;
+ this._assignTasks.enqueue(observer, {value});
+ }
+
+ _applyValues(targets, response, ignoreStale) {
+ if (!Array.isArray(response)) { return; }
+
+ for (let i = 0, ii = targets.length; i < ii; ++i) {
+ const [observer, task] = targets[i];
+ const {error, result} = response[i];
+ const stale = (task !== null && task.stale);
+
+ if (error) {
+ if (typeof this._onError === 'function') {
+ this._onError(error, stale, observer.element, observer.metadata);
+ }
+ continue;
+ }
+
+ if (stale && !ignoreStale) { continue; }
+
+ observer.value = result;
+ observer.hasValue = true;
+ this._setElementValue(observer.element, result);
+ }
+ }
+
+ _createObserver(element) {
+ if (this._elementMap.has(element) || this._shouldIgnoreElement(element)) { return; }
+
+ const metadata = this._createElementMetadata(element);
+ const nodeName = element.nodeName.toUpperCase();
+ const ancestors = this._getAncestors(element);
+ const observer = {
+ element,
+ ancestors,
+ type: (nodeName === 'INPUT' ? element.type : null),
+ value: null,
+ hasValue: false,
+ onChange: null,
+ metadata
+ };
+ observer.onChange = this._onElementChange.bind(this, observer);
+ this._elementMap.set(element, observer);
+
+ element.addEventListener('change', observer.onChange, false);
+
+ for (const ancestor of ancestors) {
+ let observers = this._elementAncestorMap.get(ancestor);
+ if (typeof observers === 'undefined') {
+ observers = new Set();
+ this._elementAncestorMap.set(ancestor, observers);
+ }
+ observers.add(observer);
+ }
+
+ this._updateTasks.enqueue(observer);
+ }
+
+ _removeObserver(observer) {
+ const {element, ancestors} = observer;
+
+ element.removeEventListener('change', observer.onChange, false);
+ observer.onChange = null;
+
+ this._elementMap.delete(element);
+
+ for (const ancestor of ancestors) {
+ const observers = this._elementAncestorMap.get(ancestor);
+ if (typeof observers === 'undefined') { continue; }
+
+ observers.delete(observer);
+ if (observers.size === 0) {
+ this._elementAncestorMap.delete(ancestor);
+ }
+ }
+ }
+
+ _isObserverStale(observer) {
+ const {element, type, metadata} = observer;
+ const nodeName = element.nodeName.toUpperCase();
+ return !(
+ type === (nodeName === 'INPUT' ? element.type : null) &&
+ this._compareElementMetadata(metadata, this._createElementMetadata(element))
+ );
+ }
+
+ _shouldIgnoreElement(element) {
+ for (const selector of this._ignoreSelectors) {
+ if (element.matches(selector)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ _getAncestors(node) {
+ const root = this._observingElement;
+ const results = [];
+ while (true) {
+ results.push(node);
+ if (node === root) { break; }
+ node = node.parentNode;
+ if (node === null) { break; }
+ }
+ return results;
+ }
+
+ _setElementValue(element, value) {
+ switch (element.nodeName.toUpperCase()) {
+ case 'INPUT':
+ switch (element.type) {
+ case 'checkbox':
+ element.checked = value;
+ break;
+ case 'text':
+ case 'number':
+ element.value = value;
+ break;
+ }
+ break;
+ case 'TEXTAREA':
+ case 'SELECT':
+ element.value = value;
+ break;
+ }
+ }
+
+ _getElementValue(element) {
+ switch (element.nodeName.toUpperCase()) {
+ case 'INPUT':
+ switch (element.type) {
+ case 'checkbox':
+ return !!element.checked;
+ case 'text':
+ return `${element.value}`;
+ case 'number':
+ return this._getInputNumberValue(element);
+ }
+ break;
+ case 'TEXTAREA':
+ case 'SELECT':
+ return element.value;
+ }
+ return null;
+ }
+
+ _getInputNumberValue(element) {
+ let value = parseFloat(element.value);
+ if (!Number.isFinite(value)) { return 0; }
+
+ let {min, max, step} = element;
+ min = this._stringValueToNumberOrNull(min);
+ max = this._stringValueToNumberOrNull(max);
+ step = this._stringValueToNumberOrNull(step);
+ if (typeof min === 'number') { value = Math.max(value, min); }
+ if (typeof max === 'number') { value = Math.min(value, max); }
+ if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; }
+ return value;
+ }
+
+ _stringValueToNumberOrNull(value) {
+ if (typeof value !== 'string' || value.length === 0) {
+ return null;
+ }
+
+ const number = parseFloat(value);
+ return !Number.isNaN(number) ? number : null;
+ }
+}
diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js
index 0e8f4462..59fea9f6 100644
--- a/ext/mixed/js/dom.js
+++ b/ext/mixed/js/dom.js
@@ -77,6 +77,24 @@ class DOM {
return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : '');
}
+ static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) {
+ const target = document;
+ const options = false;
+ const fullscreenEventNames = [
+ 'fullscreenchange',
+ 'MSFullscreenChange',
+ 'mozfullscreenchange',
+ 'webkitfullscreenchange'
+ ];
+ for (const eventName of fullscreenEventNames) {
+ if (eventListenerCollection === null) {
+ target.addEventListener(eventName, onFullscreenChanged, options);
+ } else {
+ eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options);
+ }
+ }
+ }
+
static getFullscreenElement() {
return (
document.fullscreenElement ||
@@ -86,4 +104,42 @@ class DOM {
null
);
}
+
+ static getNodesInRange(range) {
+ const end = range.endContainer;
+ const nodes = [];
+ for (let node = range.startContainer; node !== null; node = DOM.getNextNode(node)) {
+ nodes.push(node);
+ if (node === end) { break; }
+ }
+ return nodes;
+ }
+
+ static getNextNode(node) {
+ let next = node.firstChild;
+ if (next === null) {
+ while (true) {
+ next = node.nextSibling;
+ if (next !== null) { break; }
+
+ next = node.parentNode;
+ if (next === null) { break; }
+
+ node = next;
+ }
+ }
+ return next;
+ }
+
+ static anyNodeMatchesSelector(nodes, selector) {
+ const ELEMENT_NODE = Node.ELEMENT_NODE;
+ for (let node of nodes) {
+ for (; node !== null; node = node.parentNode) {
+ if (node.nodeType !== ELEMENT_NODE) { continue; }
+ if (node.matches(selector)) { return true; }
+ break;
+ }
+ }
+ return false;
+ }
}
diff --git a/ext/mixed/js/dynamic-loader.js b/ext/mixed/js/dynamic-loader.js
index ce946109..981d1ee5 100644
--- a/ext/mixed/js/dynamic-loader.js
+++ b/ext/mixed/js/dynamic-loader.js
@@ -16,19 +16,41 @@
*/
/* global
- * apiInjectStylesheet
+ * api
*/
const dynamicLoader = (() => {
const injectedStylesheets = new Map();
+ const injectedStylesheetsWithParent = new WeakMap();
- async function loadStyle(id, type, value, useWebExtensionApi=false) {
+ function getInjectedStylesheet(id, parentNode) {
+ if (parentNode === null) {
+ return injectedStylesheets.get(id);
+ }
+ const map = injectedStylesheetsWithParent.get(parentNode);
+ return typeof map !== 'undefined' ? map.get(id) : void 0;
+ }
+
+ function setInjectedStylesheet(id, parentNode, value) {
+ if (parentNode === null) {
+ injectedStylesheets.set(id, value);
+ return;
+ }
+ let map = injectedStylesheetsWithParent.get(parentNode);
+ if (typeof map === 'undefined') {
+ map = new Map();
+ injectedStylesheetsWithParent.set(parentNode, map);
+ }
+ map.set(id, value);
+ }
+
+ async function loadStyle(id, type, value, useWebExtensionApi=false, parentNode=null) {
if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) {
// Permissions error will occur if trying to use the WebExtension API to inject into an extension page
useWebExtensionApi = false;
}
- let styleNode = injectedStylesheets.get(id);
+ let styleNode = getInjectedStylesheet(id, parentNode);
if (typeof styleNode !== 'undefined') {
if (styleNode === null) {
// Previously injected via WebExtension API
@@ -38,21 +60,30 @@ const dynamicLoader = (() => {
styleNode = null;
}
+ if (type === 'file-content') {
+ value = await api.getStylesheetContent(value);
+ type = 'code';
+ useWebExtensionApi = false;
+ }
+
if (useWebExtensionApi) {
// Inject via WebExtension API
if (styleNode !== null && styleNode.parentNode !== null) {
styleNode.parentNode.removeChild(styleNode);
}
- injectedStylesheets.set(id, null);
- await apiInjectStylesheet(type, value);
+ setInjectedStylesheet(id, parentNode, null);
+ await api.injectStylesheet(type, value);
return null;
}
// Create node in document
- const parentNode = document.head;
- if (parentNode === null) {
- throw new Error('No parent node');
+ let parentNode2 = parentNode;
+ if (parentNode2 === null) {
+ parentNode2 = document.head;
+ if (parentNode2 === null) {
+ throw new Error('No parent node');
+ }
}
// Create or reuse node
@@ -74,12 +105,12 @@ const dynamicLoader = (() => {
}
// Update parent
- if (styleNode.parentNode !== parentNode) {
- parentNode.appendChild(styleNode);
+ if (styleNode.parentNode !== parentNode2) {
+ parentNode2.appendChild(styleNode);
}
// Add to map
- injectedStylesheets.set(id, styleNode);
+ setInjectedStylesheet(id, parentNode, styleNode);
return styleNode;
}
diff --git a/ext/mixed/js/environment.js b/ext/mixed/js/environment.js
index e5bc20a7..5bd84010 100644
--- a/ext/mixed/js/environment.js
+++ b/ext/mixed/js/environment.js
@@ -32,17 +32,40 @@ class Environment {
async _loadEnvironmentInfo() {
const browser = await this._getBrowser();
- const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
- const modifierInfo = this._getModifierInfo(browser, platform.os);
+ const os = await this._getOperatingSystem();
+ const modifierInfo = this._getModifierInfo(browser, os);
return {
browser,
- platform: {
- os: platform.os
- },
+ platform: {os},
modifiers: modifierInfo
};
}
+ async _getOperatingSystem() {
+ try {
+ const {os} = await this._getPlatformInfo();
+ if (typeof os === 'string') {
+ return os;
+ }
+ } catch (e) {
+ // NOP
+ }
+ return 'unknown';
+ }
+
+ _getPlatformInfo() {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.getPlatformInfo((result) => {
+ const error = chrome.runtime.lastError;
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+ }
+
async _getBrowser() {
if (EXTENSION_IS_BROWSER_EDGE) {
return 'edge';
@@ -96,8 +119,15 @@ class Environment {
['meta', 'Super']
];
break;
- default:
- throw new Error(`Invalid OS: ${os}`);
+ default: // 'unknown', etc
+ separator = ' + ';
+ osKeys = [
+ ['alt', 'Alt'],
+ ['ctrl', 'Ctrl'],
+ ['shift', 'Shift'],
+ ['meta', 'Meta']
+ ];
+ break;
}
const isFirefox = (browser === 'firefox' || browser === 'firefox-mobile');
diff --git a/ext/mixed/js/media-loader.js b/ext/mixed/js/media-loader.js
index 64ccd715..fc6e93d1 100644
--- a/ext/mixed/js/media-loader.js
+++ b/ext/mixed/js/media-loader.js
@@ -16,7 +16,7 @@
*/
/* global
- * apiGetMedia
+ * api
*/
class MediaLoader {
@@ -84,7 +84,7 @@ class MediaLoader {
async _getMediaData(path, dictionaryName, cachedData) {
const token = this._token;
- const data = (await apiGetMedia([{path, dictionaryName}]))[0];
+ const data = (await api.getMedia([{path, dictionaryName}]))[0];
if (token === this._token && data !== null) {
const contentArrayBuffer = this._base64ToArrayBuffer(data.content);
const blob = new Blob([contentArrayBuffer], {type: data.mediaType});
diff --git a/ext/mixed/js/task-accumulator.js b/ext/mixed/js/task-accumulator.js
new file mode 100644
index 00000000..5c6fe312
--- /dev/null
+++ b/ext/mixed/js/task-accumulator.js
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class TaskAccumulator {
+ constructor(runTasks) {
+ this._deferPromise = null;
+ this._activePromise = null;
+ this._tasks = [];
+ this._tasksActive = [];
+ this._uniqueTasks = new Map();
+ this._uniqueTasksActive = new Map();
+ this._runTasksBind = this._runTasks.bind(this);
+ this._tasksCompleteBind = this._tasksComplete.bind(this);
+ this._runTasks = runTasks;
+ }
+
+ enqueue(key, data) {
+ if (this._deferPromise === null) {
+ const promise = this._activePromise !== null ? this._activePromise : Promise.resolve();
+ this._deferPromise = promise.then(this._runTasksBind);
+ }
+
+ const task = {data, stale: false};
+ if (key !== null) {
+ const activeTaskInfo = this._uniqueTasksActive.get(key);
+ if (typeof activeTaskInfo !== 'undefined') {
+ activeTaskInfo.stale = true;
+ }
+
+ this._uniqueTasks.set(key, task);
+ } else {
+ this._tasks.push(task);
+ }
+
+ return this._deferPromise;
+ }
+
+ _runTasks() {
+ this._deferPromise = null;
+
+ // Swap
+ [this._tasks, this._tasksActive] = [this._tasksActive, this._tasks];
+ [this._uniqueTasks, this._uniqueTasksActive] = [this._uniqueTasksActive, this._uniqueTasks];
+
+ const promise = this._runTasksAsync();
+ this._activePromise = promise.then(this._tasksCompleteBind);
+ return this._activePromise;
+ }
+
+ async _runTasksAsync() {
+ try {
+ const allTasks = [
+ ...this._tasksActive.map((taskInfo) => [null, taskInfo]),
+ ...this._uniqueTasksActive.entries()
+ ];
+ await this._runTasks(allTasks);
+ } catch (e) {
+ yomichan.logError(e);
+ }
+ }
+
+ _tasksComplete() {
+ this._tasksActive.length = 0;
+ this._uniqueTasksActive.clear();
+ this._activePromise = null;
+ }
+}
diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js
index b8688b08..7c705fc8 100644
--- a/ext/mixed/js/text-scanner.js
+++ b/ext/mixed/js/text-scanner.js
@@ -17,7 +17,6 @@
/* global
* DOM
- * TextSourceRange
* docRangeFromPoint
*/
@@ -29,6 +28,7 @@ class TextScanner extends EventDispatcher {
this._ignorePoint = ignorePoint;
this._search = search;
+ this._isPrepared = false;
this._ignoreNodes = null;
this._causeCurrent = null;
@@ -70,10 +70,15 @@ class TextScanner extends EventDispatcher {
return this._causeCurrent;
}
+ prepare() {
+ this._isPrepared = true;
+ this.setEnabled(this._enabled);
+ }
+
setEnabled(enabled) {
this._eventListeners.removeAllEventListeners();
this._enabled = enabled;
- if (this._enabled) {
+ if (this._enabled && this._isPrepared) {
this._hookEvents();
} else {
this.clearSelection(true);
@@ -119,20 +124,20 @@ class TextScanner extends EventDispatcher {
}
}
- getTextSourceContent(textSource, length) {
+ getTextSourceContent(textSource, length, layoutAwareScan) {
const clonedTextSource = textSource.clone();
- clonedTextSource.setEndOffset(length);
+ clonedTextSource.setEndOffset(length, layoutAwareScan);
if (this._ignoreNodes !== null && clonedTextSource.range) {
length = clonedTextSource.text().length;
while (clonedTextSource.range && length > 0) {
- const nodes = TextSourceRange.getNodesInRange(clonedTextSource.range);
- if (!TextSourceRange.anyNodeMatchesSelector(nodes, this._ignoreNodes)) {
+ const nodes = DOM.getNodesInRange(clonedTextSource.range);
+ if (!DOM.anyNodeMatchesSelector(nodes, this._ignoreNodes)) {
break;
}
--length;
- clonedTextSource.setEndOffset(length);
+ clonedTextSource.setEndOffset(length, layoutAwareScan);
}
}