diff options
-rw-r--r-- | .gitattributes | 1 | ||||
-rw-r--r-- | ext/bg/background.html | 9 | ||||
-rw-r--r-- | ext/bg/context.html | 6 | ||||
-rw-r--r-- | ext/bg/guide.html | 6 | ||||
-rw-r--r-- | ext/bg/js/api.js | 58 | ||||
-rw-r--r-- | ext/bg/js/backend.js | 8 | ||||
-rw-r--r-- | ext/bg/js/context.js | 4 | ||||
-rw-r--r-- | ext/bg/js/handlebars.js | 4 | ||||
-rw-r--r-- | ext/bg/js/options.js | 4 | ||||
-rw-r--r-- | ext/bg/js/search.js | 173 | ||||
-rw-r--r-- | ext/bg/js/settings-popup-preview.js | 7 | ||||
-rw-r--r-- | ext/bg/js/translator.js | 2 | ||||
-rw-r--r-- | ext/bg/legal.html | 6 | ||||
-rw-r--r-- | ext/bg/search.html | 23 | ||||
-rw-r--r-- | ext/bg/settings-popup-preview.html | 6 | ||||
-rw-r--r-- | ext/bg/settings.html | 6 | ||||
-rw-r--r-- | ext/fg/float.html | 6 | ||||
-rw-r--r-- | ext/fg/js/api.js | 8 | ||||
-rw-r--r-- | ext/fg/js/frontend.js | 411 | ||||
-rw-r--r-- | ext/fg/js/popup.js | 2 | ||||
-rw-r--r-- | ext/manifest.json | 5 | ||||
-rw-r--r-- | ext/mixed/js/display.js | 23 | ||||
-rw-r--r-- | ext/mixed/js/extension.js | 38 |
23 files changed, 570 insertions, 246 deletions
diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2000050e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tmpl/*.html text eol=lf 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; +} |