aboutsummaryrefslogtreecommitdiff
path: root/ext/mixed/js
diff options
context:
space:
mode:
authorAlex Yatskov <alex@foosoft.net>2020-05-22 17:46:16 -0700
committerAlex Yatskov <alex@foosoft.net>2020-05-22 17:46:16 -0700
commit1480288561cb8b9fb87ad711d970c548329fea98 (patch)
tree87c2247f6d144407afcc6de316bbacc264582248 /ext/mixed/js
parentf2186c51e4ef219d158735d30a32bbf3e49c4e1a (diff)
parentd0dcff765f740bf6f0f6523b09cb8b21eb85cd93 (diff)
Merge branch 'master' into testing
Diffstat (limited to 'ext/mixed/js')
-rw-r--r--ext/mixed/js/api.js196
-rw-r--r--ext/mixed/js/audio-system.js118
-rw-r--r--ext/mixed/js/core.js132
-rw-r--r--ext/mixed/js/display-generator.js85
-rw-r--r--ext/mixed/js/display.js84
-rw-r--r--ext/mixed/js/dom.js24
-rw-r--r--ext/mixed/js/dynamic-loader-sentinel.js18
-rw-r--r--ext/mixed/js/dynamic-loader.js139
-rw-r--r--ext/mixed/js/environment.js114
-rw-r--r--ext/mixed/js/japanese.js505
-rw-r--r--ext/mixed/js/media-loader.js107
-rw-r--r--ext/mixed/js/object-property-accessor.js125
-rw-r--r--ext/mixed/js/text-scanner.js436
13 files changed, 1712 insertions, 371 deletions
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;