aboutsummaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
Diffstat (limited to 'ext')
-rw-r--r--ext/bg/background.html9
-rw-r--r--ext/bg/context.html6
-rw-r--r--ext/bg/guide.html6
-rw-r--r--ext/bg/js/api.js58
-rw-r--r--ext/bg/js/backend.js8
-rw-r--r--ext/bg/js/context.js4
-rw-r--r--ext/bg/js/handlebars.js4
-rw-r--r--ext/bg/js/options.js4
-rw-r--r--ext/bg/js/search.js173
-rw-r--r--ext/bg/js/settings-popup-preview.js7
-rw-r--r--ext/bg/js/translator.js2
-rw-r--r--ext/bg/legal.html6
-rw-r--r--ext/bg/search.html23
-rw-r--r--ext/bg/settings-popup-preview.html6
-rw-r--r--ext/bg/settings.html6
-rw-r--r--ext/fg/float.html6
-rw-r--r--ext/fg/js/api.js8
-rw-r--r--ext/fg/js/frontend.js411
-rw-r--r--ext/fg/js/popup.js2
-rw-r--r--ext/manifest.json5
-rw-r--r--ext/mixed/js/display.js23
-rw-r--r--ext/mixed/js/extension.js38
22 files changed, 569 insertions, 246 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html
index 194d4a45..3ab68639 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -3,8 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>Background</title>
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
</head>
<body>
+ <div id="clipboard-paste-target" contenteditable="true"></div>
+
<script src="/mixed/lib/dexie.min.js"></script>
<script src="/mixed/lib/handlebars.min.js"></script>
<script src="/mixed/lib/jszip.min.js"></script>
diff --git a/ext/bg/context.html b/ext/bg/context.html
index 48fa463f..7e08dddd 100644
--- a/ext/bg/context.html
+++ b/ext/bg/context.html
@@ -3,6 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap-toggle/bootstrap-toggle.min.css">
diff --git a/ext/bg/guide.html b/ext/bg/guide.html
index 2a602f1f..ff9c71ee 100644
--- a/ext/bg/guide.html
+++ b/ext/bg/guide.html
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Welcome to Yomichan!</title>
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">
</head>
diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js
index 93d9c155..3209cc31 100644
--- a/ext/bg/js/api.js
+++ b/ext/bg/js/api.js
@@ -21,6 +21,52 @@ function apiOptionsGet(optionsContext) {
return utilBackend().getOptions(optionsContext);
}
+async function apiOptionsSet(changedOptions, optionsContext, source) {
+ const options = await apiOptionsGet(optionsContext);
+
+ function getValuePaths(obj) {
+ let valuePaths = [];
+ let nodes = [{
+ obj,
+ path: []
+ }];
+ while (nodes.length > 0) {
+ let node = nodes.pop();
+ Object.keys(node.obj).forEach((key) => {
+ let path = node.path.concat(key);
+ let value = node.obj[key];
+ if (typeof value === 'object') {
+ nodes.unshift({
+ obj: value,
+ path: path
+ });
+ } else {
+ valuePaths.push([value, path]);
+ }
+ });
+ }
+ return valuePaths;
+ }
+
+ function modifyOption(path, value, options) {
+ let pivot = options;
+ for (let pathKey of path.slice(0, -1)) {
+ if (!(pathKey in pivot)) {
+ return false;
+ }
+ pivot = pivot[pathKey];
+ }
+ pivot[path[path.length - 1]] = value;
+ return true;
+ }
+
+ for (let [value, path] of getValuePaths(changedOptions)) {
+ modifyOption(path, value, options);
+ }
+
+ await apiOptionsSave(source);
+}
+
function apiOptionsGetFull() {
return utilBackend().getFullOptions();
}
@@ -153,7 +199,7 @@ async function apiCommandExec(command, params) {
}
apiCommandExec.handlers = {
search: async (params) => {
- const url = chrome.extension.getURL('/bg/search.html');
+ const url = chrome.runtime.getURL('/bg/search.html');
if (!(params && params.newTab)) {
try {
const tab = await apiFindTab(1000, (url2) => (
@@ -181,7 +227,7 @@ apiCommandExec.handlers = {
chrome.runtime.openOptionsPage();
} else {
const manifest = chrome.runtime.getManifest();
- const url = chrome.extension.getURL(manifest.options_ui.page);
+ const url = chrome.runtime.getURL(manifest.options_ui.page);
chrome.tabs.create({url});
}
},
@@ -401,3 +447,11 @@ async function apiFocusTab(tab) {
// Edge throws exception for no reason here.
}
}
+
+async function apiClipboardGet() {
+ const clipboardPasteTarget = utilBackend().clipboardPasteTarget;
+ clipboardPasteTarget.innerText = '';
+ clipboardPasteTarget.focus();
+ document.execCommand('paste');
+ return clipboardPasteTarget.innerText;
+}
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index f29230a2..71393467 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -30,6 +30,8 @@ class Backend {
this.isPreparedResolve = null;
this.isPreparedPromise = new Promise((resolve) => (this.isPreparedResolve = resolve));
+ this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target');
+
this.apiForwarder = new BackendApiForwarder();
}
@@ -45,7 +47,7 @@ class Backend {
const options = this.getOptionsSync(this.optionsContext);
if (options.general.showGuide) {
- chrome.tabs.create({url: chrome.extension.getURL('/bg/guide.html')});
+ chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')});
}
this.isPreparedResolve();
@@ -175,6 +177,7 @@ class Backend {
Backend.messageHandlers = {
optionsGet: ({optionsContext}) => apiOptionsGet(optionsContext),
+ optionsSet: ({changedOptions, optionsContext, source}) => apiOptionsSet(changedOptions, optionsContext, source),
kanjiFind: ({text, optionsContext}) => apiKanjiFind(text, optionsContext),
termsFind: ({text, optionsContext}) => apiTermsFind(text, optionsContext),
definitionAdd: ({definition, mode, context, optionsContext}) => apiDefinitionAdd(definition, mode, context, optionsContext),
@@ -187,7 +190,8 @@ Backend.messageHandlers = {
forward: ({action, params}, sender) => apiForward(action, params, sender),
frameInformationGet: (params, sender) => apiFrameInformationGet(sender),
injectStylesheet: ({css}, sender) => apiInjectStylesheet(css, sender),
- getEnvironmentInfo: () => apiGetEnvironmentInfo()
+ getEnvironmentInfo: () => apiGetEnvironmentInfo(),
+ clipboardGet: () => apiClipboardGet()
};
window.yomichan_backend = new Backend();
diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js
index 8e1dbce6..3fb27f0d 100644
--- a/ext/bg/js/context.js
+++ b/ext/bg/js/context.js
@@ -55,8 +55,8 @@ $(document).ready(utilAsync(() => {
const manifest = chrome.runtime.getManifest();
- setupButtonEvents('.action-open-search', 'search', chrome.extension.getURL('/bg/search.html'));
- setupButtonEvents('.action-open-options', 'options', chrome.extension.getURL(manifest.options_ui.page));
+ setupButtonEvents('.action-open-search', 'search', chrome.runtime.getURL('/bg/search.html'));
+ setupButtonEvents('.action-open-options', 'options', chrome.runtime.getURL(manifest.options_ui.page));
setupButtonEvents('.action-open-help', 'help');
const optionsContext = {
diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js
index 92764a20..fba437da 100644
--- a/ext/bg/js/handlebars.js
+++ b/ext/bg/js/handlebars.js
@@ -49,13 +49,13 @@ function handlebarsFuriganaPlain(options) {
let result = '';
for (const seg of segs) {
if (seg.furigana) {
- result += `${seg.text}[${seg.furigana}]`;
+ result += ` ${seg.text}[${seg.furigana}]`;
} else {
result += seg.text;
}
}
- return result;
+ return result.trimLeft();
}
function handlebarsKanjiLinks(options) {
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index 4854cd65..be1ccfbb 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -279,7 +279,9 @@ function profileOptionsCreateDefaults() {
popupTheme: 'default',
popupOuterTheme: 'default',
customPopupCss: '',
- customPopupOuterCss: ''
+ customPopupOuterCss: '',
+ enableWanakana: true,
+ enableClipboardMonitor: false
},
audio: {
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index 431478c9..dbfcb15d 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -17,6 +17,12 @@
*/
+let IS_FIREFOX = null;
+(async () => {
+ const {browser} = await apiGetEnvironmentInfo();
+ IS_FIREFOX = ['firefox', 'firefox-mobile'].includes(browser);
+})();
+
class DisplaySearch extends Display {
constructor() {
super(document.querySelector('#spinner'), document.querySelector('#content'));
@@ -29,8 +35,14 @@ class DisplaySearch extends Display {
this.search = document.querySelector('#search');
this.query = document.querySelector('#query');
this.intro = document.querySelector('#intro');
+ this.clipboardMonitorEnable = document.querySelector('#clipboard-monitor-enable');
+ this.wanakanaEnable = document.querySelector('#wanakana-enable');
+
this.introVisible = true;
this.introAnimationTimer = null;
+
+ this.clipboardMonitorIntervalId = null;
+ this.clipboardPrevText = null;
}
static create() {
@@ -49,16 +61,69 @@ class DisplaySearch extends Display {
if (this.query !== null) {
this.query.addEventListener('input', () => this.onSearchInput(), false);
+ if (this.wanakanaEnable !== null) {
+ if (this.options.general.enableWanakana === true) {
+ this.wanakanaEnable.checked = true;
+ window.wanakana.bind(this.query);
+ } else {
+ this.wanakanaEnable.checked = false;
+ }
+ this.wanakanaEnable.addEventListener('change', (e) => {
+ const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
+ if (e.target.checked) {
+ window.wanakana.bind(this.query);
+ this.query.value = window.wanakana.toKana(query);
+ apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext());
+ } else {
+ window.wanakana.unbind(this.query);
+ this.query.value = query;
+ apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext());
+ }
+ this.onSearchQueryUpdated(this.query.value, false);
+ });
+ }
+
const query = DisplaySearch.getSearchQueryFromLocation(window.location.href);
if (query !== null) {
- this.query.value = window.wanakana.toKana(query);
- this.onSearchQueryUpdated(query, false);
+ if (this.isWanakanaEnabled()) {
+ this.query.value = window.wanakana.toKana(query);
+ } else {
+ this.query.value = query;
+ }
+ this.onSearchQueryUpdated(this.query.value, false);
}
-
- window.wanakana.bind(this.query);
+ }
+ if (this.clipboardMonitorEnable !== null) {
+ if (this.options.general.enableClipboardMonitor === true) {
+ this.clipboardMonitorEnable.checked = true;
+ this.startClipboardMonitor();
+ } else {
+ this.clipboardMonitorEnable.checked = false;
+ }
+ this.clipboardMonitorEnable.addEventListener('change', (e) => {
+ if (e.target.checked) {
+ chrome.permissions.request(
+ {permissions: ['clipboardRead']},
+ (granted) => {
+ if (granted) {
+ this.startClipboardMonitor();
+ apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext());
+ } else {
+ e.target.checked = false;
+ }
+ }
+ );
+ } else {
+ this.stopClipboardMonitor();
+ apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext());
+ }
+ });
}
+ window.addEventListener('popstate', (e) => this.onPopState(e));
+
this.updateSearchButton();
+ this.initClipboardMonitor();
} catch (e) {
this.onError(e);
}
@@ -79,6 +144,11 @@ class DisplaySearch extends Display {
onSearchInput() {
this.updateSearchButton();
+
+ const queryElementRect = this.query.getBoundingClientRect();
+ if (queryElementRect.top < 0 || queryElementRect.bottom > window.innerHeight) {
+ this.query.scrollIntoView();
+ }
}
onSearch(e) {
@@ -90,10 +160,60 @@ class DisplaySearch extends Display {
const query = this.query.value;
const queryString = query.length > 0 ? `?query=${encodeURIComponent(query)}` : '';
- window.history.replaceState(null, '', `${window.location.pathname}${queryString}`);
+ window.history.pushState(null, '', `${window.location.pathname}${queryString}`);
this.onSearchQueryUpdated(query, true);
}
+ onPopState(e) {
+ const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
+ if (this.query !== null) {
+ if (this.isWanakanaEnabled()) {
+ this.query.value = window.wanakana.toKana(query);
+ } else {
+ this.query.value = query;
+ }
+ }
+
+ this.onSearchQueryUpdated(this.query.value, false);
+ }
+
+ onKeyDown(e) {
+ const key = Display.getKeyFromEvent(e);
+
+ let activeModifierMap = {
+ 'Control': e.ctrlKey,
+ 'Meta': e.metaKey,
+ 'ANY_MOD': true
+ };
+
+ const ignoreKeys = {
+ 'ANY_MOD': ['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageDown', 'PageUp', 'Home', 'End']
+ .concat(
+ Array.from(Array(24).keys())
+ .map(i => `F${i + 1}`)
+ ),
+ 'Control': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'],
+ 'Meta': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'],
+ 'OS': [],
+ 'Alt': [],
+ 'AltGraph': [],
+ 'Shift': []
+ }
+
+ let preventFocus = false;
+ for (const [modifier, keys] of Object.entries(ignoreKeys)) {
+ const modifierActive = activeModifierMap[modifier];
+ if (key === modifier || (modifierActive && keys.includes(key))) {
+ preventFocus = true;
+ break;
+ }
+ }
+
+ if (!super.onKeyDown(e) && !preventFocus && document.activeElement !== this.query) {
+ this.query.focus({preventScroll: true});
+ }
+ }
+
async onSearchQueryUpdated(query, animate) {
try {
const valid = (query.length > 0);
@@ -125,6 +245,49 @@ class DisplaySearch extends Display {
}
}
+ initClipboardMonitor() {
+ // ignore copy from search page
+ window.addEventListener('copy', (e) => {
+ this.clipboardPrevText = document.getSelection().toString().trim();
+ });
+ }
+
+ startClipboardMonitor() {
+ this.clipboardMonitorIntervalId = setInterval(async () => {
+ let curText = null;
+ // TODO get rid of this and figure out why apiClipboardGet doesn't work on Firefox
+ if (IS_FIREFOX) {
+ curText = (await navigator.clipboard.readText()).trim();
+ } else if (IS_FIREFOX === false) {
+ curText = (await apiClipboardGet()).trim();
+ }
+ if (curText && (curText !== this.clipboardPrevText)) {
+ if (this.isWanakanaEnabled()) {
+ this.query.value = window.wanakana.toKana(curText);
+ } else {
+ this.query.value = curText;
+ }
+
+ const queryString = curText.length > 0 ? `?query=${encodeURIComponent(curText)}` : '';
+ window.history.pushState(null, '', `${window.location.pathname}${queryString}`);
+ this.onSearchQueryUpdated(this.query.value, true);
+
+ this.clipboardPrevText = curText;
+ }
+ }, 100);
+ }
+
+ stopClipboardMonitor() {
+ if (this.clipboardMonitorIntervalId) {
+ clearInterval(this.clipboardMonitorIntervalId);
+ this.clipboardMonitorIntervalId = null;
+ }
+ }
+
+ isWanakanaEnabled() {
+ return this.wanakanaEnable !== null && this.wanakanaEnable.checked;
+ }
+
getOptionsContext() {
return this.optionsContext;
}
diff --git a/ext/bg/js/settings-popup-preview.js b/ext/bg/js/settings-popup-preview.js
index b12fb726..7d641c46 100644
--- a/ext/bg/js/settings-popup-preview.js
+++ b/ext/bg/js/settings-popup-preview.js
@@ -159,8 +159,11 @@ class SettingsPopupPreview {
range.selectNode(textNode);
const source = new TextSourceRange(range, range.toString(), null);
- this.frontend.textSourceLast = null;
- await this.frontend.searchSource(source, 'script');
+ try {
+ await this.frontend.searchSource(source, 'script');
+ } finally {
+ source.cleanup();
+ }
await this.frontend.lastShowPromise;
if (this.frontend.popup.isVisible()) {
diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js
index ee012d96..9d90136b 100644
--- a/ext/bg/js/translator.js
+++ b/ext/bg/js/translator.js
@@ -31,7 +31,7 @@ class Translator {
}
if (!this.deinflector) {
- const url = chrome.extension.getURL('/bg/lang/deinflect.json');
+ const url = chrome.runtime.getURL('/bg/lang/deinflect.json');
const reasons = await requestJson(url, 'GET');
this.deinflector = new Deinflector(reasons);
}
diff --git a/ext/bg/legal.html b/ext/bg/legal.html
index 26ac033d..30927da6 100644
--- a/ext/bg/legal.html
+++ b/ext/bg/legal.html
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Yomichan Legal</title>
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">
</head>
diff --git a/ext/bg/search.html b/ext/bg/search.html
index 9d28b358..91140b95 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Yomichan Search</title>
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/css/display.css">
@@ -19,7 +25,22 @@
<p style="margin-bottom: 0;">Search your installed dictionaries by entering a Japanese expression into the field below.</p>
</div>
- <form class="input-group" style="padding-top: 10px;">
+ <div class="input-group" style="padding-top: 10px; font-size: 20px; user-select: none;">
+ <span title="Enable kana input method" class="input-group-text">
+ <label>
+ あ
+ <input type="checkbox" id="wanakana-enable" />
+ </label>
+ </span>
+ <span title="Enable clipboard monitor" class="input-group-text">
+ <label>
+ <span class="glyphicon glyphicon-paste"></span>
+ <input type="checkbox" id="clipboard-monitor-enable" />
+ </label>
+ </span>
+ </div>
+
+ <form class="input-group">
<input type="text" class="form-control" placeholder="Search for..." id="query" autofocus>
<span class="input-group-btn">
<input type="submit" class="btn btn-default form-control" id="search" value="Search">
diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html
index 07caa271..d27a9a33 100644
--- a/ext/bg/settings-popup-preview.html
+++ b/ext/bg/settings-popup-preview.html
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Yomichan Popup Preview</title>
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" type="text/css" href="/fg/css/client.css" id="client-css">
<style>
html {
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 9b1c4366..a3b75576 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Yomichan Options</title>
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">
<link rel="stylesheet" type="text/css" href="/bg/css/settings.css">
diff --git a/ext/fg/float.html b/ext/fg/float.html
index 580a7963..aec3db20 100644
--- a/ext/fg/float.html
+++ b/ext/fg/float.html
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title></title>
+ <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
+ <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
+ <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
+ <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
+ <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">
<link rel="stylesheet" href="/mixed/css/display.css">
<link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default">
<link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark">
diff --git a/ext/fg/js/api.js b/ext/fg/js/api.js
index b0746b85..54818702 100644
--- a/ext/fg/js/api.js
+++ b/ext/fg/js/api.js
@@ -21,6 +21,10 @@ function apiOptionsGet(optionsContext) {
return utilInvoke('optionsGet', {optionsContext});
}
+function apiOptionsSet(changedOptions, optionsContext, source) {
+ return utilInvoke('optionsSet', {changedOptions, optionsContext, source});
+}
+
function apiTermsFind(text, optionsContext) {
return utilInvoke('termsFind', {text, optionsContext});
}
@@ -72,3 +76,7 @@ function apiInjectStylesheet(css) {
function apiGetEnvironmentInfo() {
return utilInvoke('getEnvironmentInfo');
}
+
+function apiClipboardGet() {
+ return utilInvoke('clipboardGet');
+}
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
index e854f74e..e67008df 100644
--- a/ext/fg/js/frontend.js
+++ b/ext/fg/js/frontend.js
@@ -20,8 +20,8 @@
class Frontend {
constructor(popup, ignoreNodes) {
this.popup = popup;
- this.popupTimer = null;
- this.textSourceLast = null;
+ this.popupTimerPromise = null;
+ this.textSourceCurrent = null;
this.pendingLookup = false;
this.options = null;
this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null);
@@ -32,12 +32,10 @@ class Frontend {
};
this.primaryTouchIdentifier = null;
- this.contextMenuChecking = false;
- this.contextMenuPrevent = false;
- this.contextMenuPreviousRange = null;
- this.mouseDownPrevent = false;
- this.clickPrevent = false;
- this.scrollPrevent = false;
+ this.preventNextContextMenu = false;
+ this.preventNextMouseDown = false;
+ this.preventNextClick = false;
+ this.preventScroll = false;
this.enabled = false;
this.eventListeners = [];
@@ -74,7 +72,7 @@ class Frontend {
}
onMouseOver(e) {
- if (e.target === this.popup.container && this.popupTimer !== null) {
+ if (e.target === this.popup.container) {
this.popupTimerClear();
}
}
@@ -82,10 +80,7 @@ class Frontend {
onMouseMove(e) {
this.popupTimerClear();
- if (
- this.pendingLookup ||
- (e.buttons & 0x1) !== 0x0 // Left mouse button
- ) {
+ if (this.pendingLookup || Frontend.isMouseButton('primary', e)) {
return;
}
@@ -93,65 +88,60 @@ class Frontend {
const scanningModifier = scanningOptions.modifier;
if (!(
Frontend.isScanningModifierPressed(scanningModifier, e) ||
- (scanningOptions.middleMouse && (e.buttons & 0x4) !== 0x0) // Middle mouse button
+ (scanningOptions.middleMouse && Frontend.isMouseButton('auxiliary', e))
)) {
return;
}
const search = async () => {
- try {
- await this.searchAt(e.clientX, e.clientY, 'mouse');
- } catch (e) {
- this.onError(e);
+ if (scanningModifier === 'none') {
+ if (!await this.popupTimerWait()) {
+ // Aborted
+ return;
+ }
}
+
+ await this.searchAt(e.clientX, e.clientY, 'mouse');
};
- if (scanningModifier === 'none') {
- this.popupTimerSet(search);
- } else {
- search();
- }
+ search();
}
onMouseDown(e) {
- if (this.mouseDownPrevent) {
- this.setMouseDownPrevent(false, false);
- this.setClickPrevent(true);
+ if (this.preventNextMouseDown) {
+ this.preventNextMouseDown = false;
+ this.preventNextClick = true;
e.preventDefault();
e.stopPropagation();
return false;
}
- this.popupTimerClear();
- this.searchClear(true);
+ if (e.button === 0) {
+ this.popupTimerClear();
+ this.searchClear(true);
+ }
}
onMouseOut(e) {
this.popupTimerClear();
}
- onWindowMessage(e) {
- const action = e.data;
- const handlers = Frontend.windowMessageHandlers;
- if (handlers.hasOwnProperty(action)) {
- const handler = handlers[action];
- handler(this);
+ onClick(e) {
+ if (this.preventNextClick) {
+ this.preventNextClick = false;
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
}
}
- async onResize() {
- if (this.textSourceLast !== null && await this.popup.isVisibleAsync()) {
- const textSource = this.textSourceLast;
- this.lastShowPromise = this.popup.showContent(
- textSource.getRect(),
- textSource.getWritingMode()
- );
- }
+ onAuxClick(e) {
+ this.preventNextContextMenu = false;
}
- onClick(e) {
- if (this.clickPrevent) {
- this.setClickPrevent(false);
+ onContextMenu(e) {
+ if (this.preventNextContextMenu) {
+ this.preventNextContextMenu = false;
e.preventDefault();
e.stopPropagation();
return false;
@@ -159,28 +149,58 @@ class Frontend {
}
onTouchStart(e) {
- if (this.primaryTouchIdentifier !== null && this.getIndexOfTouch(e.touches, this.primaryTouchIdentifier) >= 0) {
+ if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) {
return;
}
- let touch = this.getPrimaryTouch(e.changedTouches);
- if (this.selectionContainsPoint(window.getSelection(), touch.clientX, touch.clientY)) {
- touch = null;
+ this.preventScroll = false;
+ this.preventNextContextMenu = false;
+ this.preventNextMouseDown = false;
+ this.preventNextClick = false;
+
+ const primaryTouch = e.changedTouches[0];
+ if (Frontend.selectionContainsPoint(window.getSelection(), primaryTouch.clientX, primaryTouch.clientY)) {
+ return;
}
- this.setPrimaryTouch(touch);
- }
+ this.primaryTouchIdentifier = primaryTouch.identifier;
- onTouchEnd(e) {
- if (this.primaryTouchIdentifier === null) {
+ if (this.pendingLookup) {
return;
}
- if (this.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0) {
+ const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null;
+
+ this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart')
+ .then(() => {
+ if (
+ this.textSourceCurrent === null ||
+ this.textSourceCurrent.equals(textSourceCurrentPrevious)
+ ) {
+ return;
+ }
+
+ this.preventScroll = true;
+ this.preventNextContextMenu = true;
+ this.preventNextMouseDown = true;
+ });
+ }
+
+ onTouchEnd(e) {
+ if (
+ this.primaryTouchIdentifier === null ||
+ this.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0
+ ) {
return;
}
- this.setPrimaryTouch(this.getPrimaryTouch(this.excludeTouches(e.touches, e.changedTouches)));
+ this.primaryTouchIdentifier = null;
+ this.preventScroll = false;
+ this.preventNextClick = false;
+ // Don't revert context menu and mouse down prevention,
+ // since these events can occur after the touch has ended.
+ // this.preventNextContextMenu = false;
+ // this.preventNextMouseDown = false;
}
onTouchCancel(e) {
@@ -188,7 +208,7 @@ class Frontend {
}
onTouchMove(e) {
- if (!this.scrollPrevent || !e.cancelable || this.primaryTouchIdentifier === null) {
+ if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) {
return;
}
@@ -198,39 +218,29 @@ class Frontend {
return;
}
- const touch = touches[index];
- this.searchFromTouch(touch.clientX, touch.clientY, 'touchMove');
+ const primaryTouch = touches[index];
+ this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove');
e.preventDefault(); // Disable scroll
}
- onContextMenu(e) {
- if (this.contextMenuPrevent) {
- this.setContextMenuPrevent(false, false);
- e.preventDefault();
- e.stopPropagation();
- return false;
+ async onResize() {
+ if (this.textSourceCurrent !== null && await this.popup.isVisibleAsync()) {
+ const textSource = this.textSourceCurrent;
+ this.lastShowPromise = this.popup.showContent(
+ textSource.getRect(),
+ textSource.getWritingMode()
+ );
}
}
- onAfterSearch(newRange, cause, searched, success) {
- if (cause === 'mouse') {
- return;
- }
-
- if (
- !this.contextMenuChecking ||
- (this.contextMenuPreviousRange === null ? newRange === null : this.contextMenuPreviousRange.equals(newRange))) {
- return;
- }
-
- if (cause === 'touchStart' && newRange !== null) {
- this.scrollPrevent = true;
+ onWindowMessage(e) {
+ const action = e.data;
+ const handlers = Frontend.windowMessageHandlers;
+ if (handlers.hasOwnProperty(action)) {
+ const handler = handlers[action];
+ handler(this);
}
-
- this.setContextMenuPrevent(true, false);
- this.setMouseDownPrevent(true, false);
- this.contextMenuChecking = false;
}
onRuntimeMessage({action, params}, sender, callback) {
@@ -271,6 +281,7 @@ class Frontend {
if (this.options.scanning.touchInputEnabled) {
this.addEventListener(window, 'click', this.onClick.bind(this));
+ this.addEventListener(window, 'auxclick', this.onAuxClick.bind(this));
this.addEventListener(window, 'touchstart', this.onTouchStart.bind(this));
this.addEventListener(window, 'touchend', this.onTouchEnd.bind(this));
this.addEventListener(window, 'touchcancel', this.onTouchCancel.bind(this));
@@ -297,47 +308,69 @@ class Frontend {
await this.popup.setOptions(this.options);
}
- popupTimerSet(callback) {
+ async popupTimerWait() {
const delay = this.options.scanning.delay;
- if (delay > 0) {
- this.popupTimer = window.setTimeout(callback, delay);
- } else {
- Promise.resolve().then(callback);
+ const promise = promiseTimeout(delay, true);
+ this.popupTimerPromise = promise;
+ try {
+ return await promise;
+ } finally {
+ if (this.popupTimerPromise === promise) {
+ this.popupTimerPromise = null;
+ }
}
}
popupTimerClear() {
- if (this.popupTimer !== null) {
- window.clearTimeout(this.popupTimer);
- this.popupTimer = null;
+ if (this.popupTimerPromise !== null) {
+ this.popupTimerPromise.resolve(false);
+ this.popupTimerPromise = null;
}
}
async searchAt(x, y, cause) {
- if (this.pendingLookup || await this.popup.containsPoint(x, y)) {
- return;
- }
+ try {
+ this.popupTimerClear();
- const textSource = docRangeFromPoint(x, y, this.options);
- return await this.searchSource(textSource, cause);
+ if (this.pendingLookup || await this.popup.containsPoint(x, y)) {
+ return;
+ }
+
+ const textSource = docRangeFromPoint(x, y, this.options);
+ if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) {
+ return;
+ }
+
+ try {
+ await this.searchSource(textSource, cause);
+ } finally {
+ if (textSource !== null) {
+ textSource.cleanup();
+ }
+ }
+ } catch (e) {
+ this.onError(e);
+ }
}
async searchSource(textSource, cause) {
- let hideResults = textSource === null;
- let searched = false;
- let success = false;
+ let results = null;
try {
- if (!hideResults && (!this.textSourceLast || !this.textSourceLast.equals(textSource))) {
- searched = true;
- this.pendingLookup = true;
- const focus = (cause === 'mouse');
- hideResults = !await this.searchTerms(textSource, focus) && !await this.searchKanji(textSource, focus);
- success = true;
+ this.pendingLookup = true;
+ if (textSource !== null) {
+ results = (
+ await this.findTerms(textSource) ||
+ await this.findKanji(textSource)
+ );
+ if (results !== null) {
+ const focus = (cause === 'mouse');
+ this.showContent(textSource, focus, results.definitions, results.type);
+ }
}
} catch (e) {
if (window.yomichan_orphaned) {
- if (textSource && this.options.scanning.modifier !== 'none') {
+ if (textSource !== null && this.options.scanning.modifier !== 'none') {
this.lastShowPromise = this.popup.showContent(
textSource.getRect(),
textSource.getWritingMode(),
@@ -348,93 +381,69 @@ class Frontend {
this.onError(e);
}
} finally {
- if (textSource !== null) {
- textSource.cleanup();
- }
- if (hideResults && this.options.scanning.autoHideResults) {
+ if (results === null && this.options.scanning.autoHideResults) {
this.searchClear(true);
}
this.pendingLookup = false;
- this.onAfterSearch(this.textSourceLast, cause, searched, success);
- }
- }
-
- async searchTerms(textSource, focus) {
- this.setTextSourceScanLength(textSource, this.options.scanning.length);
-
- const searchText = textSource.text();
- if (searchText.length === 0) {
- return false;
}
- const {definitions, length} = await apiTermsFind(searchText, this.getOptionsContext());
- if (definitions.length === 0) {
- return false;
- }
-
- textSource.setEndOffset(length);
+ return results;
+ }
+ showContent(textSource, focus, definitions, type) {
const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
const url = window.location.href;
this.lastShowPromise = this.popup.showContent(
textSource.getRect(),
textSource.getWritingMode(),
- 'terms',
+ type,
{definitions, context: {sentence, url, focus}}
);
- this.textSourceLast = textSource;
+ this.textSourceCurrent = textSource;
if (this.options.scanning.selectText) {
textSource.select();
}
+ }
+
+ async findTerms(textSource) {
+ this.setTextSourceScanLength(textSource, this.options.scanning.length);
+
+ const searchText = textSource.text();
+ if (searchText.length === 0) { return null; }
+
+ const {definitions, length} = await apiTermsFind(searchText, this.getOptionsContext());
+ if (definitions.length === 0) { return null; }
- return true;
+ textSource.setEndOffset(length);
+
+ return {definitions, type: 'terms'};
}
- async searchKanji(textSource, focus) {
+ async findKanji(textSource) {
this.setTextSourceScanLength(textSource, 1);
const searchText = textSource.text();
- if (searchText.length === 0) {
- return false;
- }
+ if (searchText.length === 0) { return null; }
const definitions = await apiKanjiFind(searchText, this.getOptionsContext());
- if (definitions.length === 0) {
- return false;
- }
-
- const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
- const url = window.location.href;
- this.lastShowPromise = this.popup.showContent(
- textSource.getRect(),
- textSource.getWritingMode(),
- 'kanji',
- {definitions, context: {sentence, url, focus}}
- );
-
- this.textSourceLast = textSource;
- if (this.options.scanning.selectText) {
- textSource.select();
- }
+ if (definitions.length === 0) { return null; }
- return true;
+ return {definitions, type: 'kanji'};
}
searchClear(changeFocus) {
this.popup.hide(changeFocus);
this.popup.clearAutoPlayTimer();
- if (this.options.scanning.selectText && this.textSourceLast) {
- this.textSourceLast.deselect();
- }
-
- this.textSourceLast = null;
- }
+ if (this.textSourceCurrent !== null) {
+ if (this.options.scanning.selectText) {
+ this.textSourceCurrent.deselect();
+ }
- getPrimaryTouch(touchList) {
- return touchList.length > 0 ? touchList[0] : null;
+ this.textSourceCurrent = null;
+ }
}
getIndexOfTouch(touchList, identifier) {
@@ -447,74 +456,7 @@ class Frontend {
return -1;
}
- excludeTouches(touchList, excludeTouchList) {
- const result = [];
- for (let r of touchList) {
- if (this.getIndexOfTouch(excludeTouchList, r.identifier) < 0) {
- result.push(r);
- }
- }
- return result;
- }
-
- setPrimaryTouch(touch) {
- if (touch === null) {
- this.primaryTouchIdentifier = null;
- this.contextMenuPreviousRange = null;
- this.contextMenuChecking = false;
- this.scrollPrevent = false;
- this.setContextMenuPrevent(false, true);
- this.setMouseDownPrevent(false, true);
- this.setClickPrevent(false);
- }
- else {
- this.primaryTouchIdentifier = touch.identifier;
- this.contextMenuPreviousRange = this.textSourceLast ? this.textSourceLast.clone() : null;
- this.contextMenuChecking = true;
- this.scrollPrevent = false;
- this.setContextMenuPrevent(false, false);
- this.setMouseDownPrevent(false, false);
- this.setClickPrevent(false);
-
- this.searchFromTouch(touch.clientX, touch.clientY, 'touchStart');
- }
- }
-
- setContextMenuPrevent(value, delay) {
- if (!delay) {
- this.contextMenuPrevent = value;
- }
- }
-
- setMouseDownPrevent(value, delay) {
- if (!delay) {
- this.mouseDownPrevent = value;
- }
- }
-
- setClickPrevent(value) {
- this.clickPrevent = value;
- }
-
- searchFromTouch(x, y, cause) {
- this.popupTimerClear();
-
- if (this.pendingLookup) {
- return;
- }
-
- const search = async () => {
- try {
- await this.searchAt(x, y, cause);
- } catch (e) {
- this.onError(e);
- }
- };
-
- search();
- }
-
- selectionContainsPoint(selection, x, y) {
+ static selectionContainsPoint(selection, x, y) {
for (let i = 0; i < selection.rangeCount; ++i) {
const range = selection.getRangeAt(i);
for (const rect of range.getClientRects()) {
@@ -557,6 +499,25 @@ class Frontend {
default: return false;
}
}
+
+ static isMouseButton(button, mouseEvent) {
+ switch (mouseEvent.type) {
+ case 'mouseup':
+ case 'mousedown':
+ case 'click': switch (button) {
+ case 'primary': return mouseEvent.button === 0;
+ case 'secondary': return mouseEvent.button === 2;
+ case 'auxiliary': return mouseEvent.button === 1;
+ default: return false;
+ }
+ default: switch (button) {
+ case 'primary': return (mouseEvent.buttons & 0x1) !== 0x0;
+ case 'secondary': return (mouseEvent.buttons & 0x2) !== 0x0;
+ case 'auxiliary': return (mouseEvent.buttons & 0x4) !== 0x0;
+ default: return false;
+ }
+ }
+ }
}
Frontend.windowMessageHandlers = {
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
index b5eb9fe2..1f9317e0 100644
--- a/ext/fg/js/popup.js
+++ b/ext/fg/js/popup.js
@@ -30,7 +30,7 @@ class Popup {
this.container.className = 'yomichan-float';
this.container.addEventListener('mousedown', e => e.stopPropagation());
this.container.addEventListener('scroll', e => e.stopPropagation());
- this.container.setAttribute('src', chrome.extension.getURL('/fg/float.html'));
+ this.container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));
this.container.style.width = '0px';
this.container.style.height = '0px';
this.injectPromise = null;
diff --git a/ext/manifest.json b/ext/manifest.json
index d4a72c8a..b9d91a81 100644
--- a/ext/manifest.json
+++ b/ext/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Yomichan (testing)",
- "version": "1.8.9",
+ "version": "1.9.5",
"description": "Japanese dictionary with Anki integration (testing)",
"icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"},
@@ -44,6 +44,9 @@
"clipboardWrite",
"unlimitedStorage"
],
+ "optional_permissions": [
+ "clipboardRead"
+ ],
"commands": {
"toggle": {
"suggested_key": {
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index b40228b0..6d992897 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -35,6 +35,7 @@ class Display {
this.persistentEventListeners = [];
this.interactive = false;
this.eventListenersActive = false;
+ this.clickScanPrevent = false;
this.windowScroll = new WindowScroll();
@@ -81,6 +82,22 @@ class Display {
}
}
+ onGlossaryMouseDown(e) {
+ if (Frontend.isMouseButton('primary', e)) {
+ this.clickScanPrevent = false;
+ }
+ }
+
+ onGlossaryMouseMove(e) {
+ this.clickScanPrevent = true;
+ }
+
+ onGlossaryMouseUp(e) {
+ if (!this.clickScanPrevent && Frontend.isMouseButton('primary', e)) {
+ this.onTermLookup(e);
+ }
+ }
+
async onTermLookup(e) {
try {
e.preventDefault();
@@ -157,8 +174,10 @@ class Display {
const handler = handlers[key];
if (handler(this, e)) {
e.preventDefault();
+ return true;
}
}
+ return false;
}
onWheel(e) {
@@ -250,7 +269,9 @@ class Display {
this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this));
this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this));
if (this.options.scanning.enablePopupSearch) {
- this.addEventListeners('.glossary-item', 'click', this.onTermLookup.bind(this));
+ this.addEventListeners('.glossary-item', 'mouseup', this.onGlossaryMouseUp.bind(this));
+ this.addEventListeners('.glossary-item', 'mousedown', this.onGlossaryMouseDown.bind(this));
+ this.addEventListeners('.glossary-item', 'mousemove', this.onGlossaryMouseMove.bind(this));
}
} else {
Display.clearEventListeners(this.eventListeners);
diff --git a/ext/mixed/js/extension.js b/ext/mixed/js/extension.js
index 861e52a5..54862e19 100644
--- a/ext/mixed/js/extension.js
+++ b/ext/mixed/js/extension.js
@@ -95,3 +95,41 @@ if (EXTENSION_IS_BROWSER_EDGE) {
// Edge does not have chrome defined.
chrome = browser;
}
+
+function promiseTimeout(delay, resolveValue) {
+ if (delay <= 0) {
+ return Promise.resolve(resolveValue);
+ }
+
+ let timer = null;
+ let promiseResolve = null;
+ let promiseReject = null;
+
+ const complete = (callback, value) => {
+ if (callback === null) { return; }
+ if (timer !== null) {
+ window.clearTimeout(timer);
+ timer = null;
+ }
+ promiseResolve = null;
+ promiseReject = null;
+ callback(value);
+ };
+
+ const resolve = (value) => complete(promiseResolve, value);
+ const reject = (value) => complete(promiseReject, value);
+
+ const promise = new Promise((resolve, reject) => {
+ promiseResolve = resolve;
+ promiseReject = reject;
+ });
+ timer = window.setTimeout(() => {
+ timer = null;
+ resolve(resolveValue);
+ }, delay);
+
+ promise.resolve = resolve;
+ promise.reject = reject;
+
+ return promise;
+}