Merge pull request #333 from siikamiika/native-popup-windows

Native popup windows
This commit is contained in:
siikamiika 2020-02-10 11:12:36 +02:00 committed by GitHub
commit cbfae2b9d7
14 changed files with 312 additions and 160 deletions

View File

@ -26,6 +26,7 @@
<script src="/bg/js/mecab.js"></script>
<script src="/bg/js/audio.js"></script>
<script src="/bg/js/backend-api-forwarder.js"></script>
<script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/conditions.js"></script>
<script src="/bg/js/database.js"></script>
<script src="/bg/js/deinflector.js"></script>

View File

@ -222,6 +222,20 @@ html:root[data-operating-system=openbsd] [data-show-for-operating-system~=openbs
display: initial;
}
html:root[data-browser=edge] [data-hide-for-browser~=edge],
html:root[data-browser=chrome] [data-hide-for-browser~=chrome],
html:root[data-browser=firefox] [data-hide-for-browser~=firefox],
html:root[data-browser=firefox-mobile] [data-hide-for-browser~=firefox-mobile],
html:root[data-operating-system=mac] [data-hide-for-operating-system~=mac],
html:root[data-operating-system=win] [data-hide-for-operating-system~=win],
html:root[data-operating-system=android] [data-hide-for-operating-system~=android],
html:root[data-operating-system=cros] [data-hide-for-operating-system~=cros],
html:root[data-operating-system=linux] [data-hide-for-operating-system~=linux],
html:root[data-operating-system=openbsd] [data-hide-for-operating-system~=openbsd] {
display: none;
}
@media screen and (max-width: 740px) {
.col-xs-6 {
float: none;

View File

@ -79,6 +79,7 @@
"type": "object",
"required": [
"enable",
"enableClipboardPopups",
"resultOutputMode",
"debugInfo",
"maxResults",
@ -111,6 +112,10 @@
"type": "boolean",
"default": true
},
"enableClipboardPopups": {
"type": "boolean",
"default": false
},
"resultOutputMode": {
"type": "string",
"enum": ["group", "merge", "split"],

View File

@ -29,6 +29,10 @@ function apiGetDisplayTemplatesHtml() {
return _apiInvoke('getDisplayTemplatesHtml');
}
function apiClipboardGet() {
return _apiInvoke('clipboardGet');
}
function _apiInvoke(action, params={}) {
const data = {action, params};
return new Promise((resolve, reject) => {

View File

@ -22,6 +22,7 @@ class Backend {
this.translator = new Translator();
this.anki = new AnkiNull();
this.mecab = new Mecab();
this.clipboardMonitor = new ClipboardMonitor();
this.options = null;
this.optionsSchema = null;
this.optionsContext = {
@ -34,6 +35,8 @@ class Backend {
this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target');
this.popupWindow = null;
this.apiForwarder = new BackendApiForwarder();
}
@ -67,6 +70,8 @@ class Backend {
this.isPreparedResolve();
this.isPreparedResolve = null;
this.isPreparedPromise = null;
this.clipboardMonitor.onClipboardText = (text) => this._onClipboardText(text);
}
onOptionsUpdated(source) {
@ -97,6 +102,10 @@ class Backend {
}
}
_onClipboardText(text) {
this._onCommandSearch({mode: 'popup', query: text});
}
_onZoomChange({tabId, oldZoomFactor, newZoomFactor}) {
const callback = () => this.checkLastError(chrome.runtime.lastError);
chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback);
@ -121,6 +130,12 @@ class Backend {
} else {
this.mecab.stopListener();
}
if (options.general.enableClipboardPopups) {
this.clipboardMonitor.start();
} else {
this.clipboardMonitor.stop();
}
}
async getOptionsSchema() {
@ -521,13 +536,30 @@ class Backend {
}
async _onApiClipboardGet() {
const clipboardPasteTarget = this.clipboardPasteTarget;
clipboardPasteTarget.value = '';
clipboardPasteTarget.focus();
document.execCommand('paste');
const result = clipboardPasteTarget.value;
clipboardPasteTarget.value = '';
return result;
/*
Notes:
document.execCommand('paste') doesn't work on Firefox.
This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
Therefore, navigator.clipboard.readText() is used on Firefox.
navigator.clipboard.readText() can't be used in Chrome for two reasons:
* Requires page to be focused, else it rejects with an exception.
* When the page is focused, Chrome will request clipboard permission, despite already
being an extension with clipboard permissions. It effectively asks for the
non-extension permission for clipboard access.
*/
const browser = await Backend._getBrowser();
if (browser === 'firefox' || browser === 'firefox-mobile') {
return await navigator.clipboard.readText();
} else {
const clipboardPasteTarget = this.clipboardPasteTarget;
clipboardPasteTarget.value = '';
clipboardPasteTarget.focus();
document.execCommand('paste');
const result = clipboardPasteTarget.value;
clipboardPasteTarget.value = '';
return result;
}
}
async _onApiGetDisplayTemplatesHtml() {
@ -565,23 +597,68 @@ class Backend {
// Command handlers
async _onCommandSearch(params) {
const url = chrome.runtime.getURL('/bg/search.html');
if (!(params && params.newTab)) {
try {
const tab = await Backend._findTab(1000, (url2) => (
url2 !== null &&
url2.startsWith(url) &&
(url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#')
));
if (tab !== null) {
await Backend._focusTab(tab);
return;
const {mode='existingOrNewTab', query} = params || {};
const options = await this.getOptions(this.optionsContext);
const {popupWidth, popupHeight} = options.general;
const baseUrl = chrome.runtime.getURL('/bg/search.html');
const queryParams = {mode};
if (query && query.length > 0) { queryParams.query = query; }
const queryString = new URLSearchParams(queryParams).toString();
const url = `${baseUrl}?${queryString}`;
const isTabMatch = (url2) => {
if (url2 === null || !url2.startsWith(baseUrl)) { return false; }
const {baseUrl: baseUrl2, queryParams: queryParams2} = parseUrl(url2);
return baseUrl2 === baseUrl && (queryParams2.mode === mode || (!queryParams2.mode && mode === 'existingOrNewTab'));
};
const openInTab = async () => {
const tab = await Backend._findTab(1000, isTabMatch);
if (tab !== null) {
await Backend._focusTab(tab);
if (queryParams.query) {
await new Promise((resolve) => chrome.tabs.sendMessage(
tab.id, {action: 'searchQueryUpdate', params: {query: queryParams.query}}, resolve
));
}
} catch (e) {
// NOP
return true;
}
};
switch (mode) {
case 'existingOrNewTab':
try {
if (await openInTab()) { return; }
} catch (e) {
// NOP
}
chrome.tabs.create({url});
return;
case 'newTab':
chrome.tabs.create({url});
return;
case 'popup':
try {
// chrome.windows not supported (e.g. on Firefox mobile)
if (!isObject(chrome.windows)) { return; }
if (await openInTab()) { return; }
// if the previous popup is open in an invalid state, close it
if (this.popupWindow !== null) {
const callback = () => this.checkLastError(chrome.runtime.lastError);
chrome.windows.remove(this.popupWindow.id, callback);
}
// open new popup
this.popupWindow = await new Promise((resolve) => chrome.windows.create(
{url, width: popupWidth, height: popupHeight, type: 'popup'},
resolve
));
} catch (e) {
// NOP
}
return;
}
chrome.tabs.create({url});
}
_onCommandHelp() {

View File

@ -0,0 +1,80 @@
/*
* Copyright (C) 2020 Alex Yatskov <alex@foosoft.net>
* Author: Alex Yatskov <alex@foosoft.net>
*
* 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 ClipboardMonitor {
constructor() {
this.timerId = null;
this.timerToken = null;
this.interval = 250;
this.previousText = null;
}
onClipboardText(_text) {
throw new Error('Override me');
}
start() {
this.stop();
// The token below is used as a unique identifier to ensure that a new clipboard monitor
// hasn't been started during the await call. The check below the await apiClipboardGet()
// call will exit early if the reference has changed.
const token = {};
const intervalCallback = async () => {
this.timerId = null;
let text = null;
try {
text = await apiClipboardGet();
} catch (e) {
// NOP
}
if (this.timerToken !== token) { return; }
if (
typeof text === 'string' &&
(text = text.trim()).length > 0 &&
text !== this.previousText
) {
this.previousText = text;
if (jpIsStringPartiallyJapanese(text)) {
this.onClipboardText(text);
}
}
this.timerId = setTimeout(intervalCallback, this.interval);
};
this.timerToken = token;
intervalCallback();
}
stop() {
this.timerToken = null;
if (this.timerId !== null) {
clearTimeout(this.timerId);
this.timerId = null;
}
}
setPreviousText(text) {
this.previousText = text;
}
}

View File

@ -30,12 +30,12 @@ function setupButtonEvents(selector, command, url) {
for (const node of nodes) {
node.addEventListener('click', (e) => {
if (e.button !== 0) { return; }
apiCommandExec(command, {newTab: e.ctrlKey});
apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'});
e.preventDefault();
}, false);
node.addEventListener('auxclick', (e) => {
if (e.button !== 1) { return; }
apiCommandExec(command, {newTab: true});
apiCommandExec(command, {mode: 'newTab'});
e.preventDefault();
}, false);

View File

@ -266,6 +266,7 @@ function profileOptionsCreateDefaults() {
return {
general: {
enable: true,
enableClipboardPopups: false,
resultOutputMode: 'group',
debugInfo: false,
maxResults: 32,

View File

@ -36,12 +36,7 @@ class DisplaySearch extends Display {
this.introVisible = true;
this.introAnimationTimer = null;
this.isFirefox = false;
this.clipboardMonitorTimerId = null;
this.clipboardMonitorTimerToken = null;
this.clipboardInterval = 250;
this.clipboardPreviousText = null;
this.clipboardMonitor = new ClipboardMonitor();
}
static create() {
@ -53,12 +48,14 @@ class DisplaySearch extends Display {
async prepare() {
try {
await this.initialize();
this.isFirefox = await DisplaySearch._isFirefox();
const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
if (this.search !== null) {
this.search.addEventListener('click', (e) => this.onSearch(e), false);
}
if (this.query !== null) {
document.documentElement.dataset.searchMode = mode;
this.query.addEventListener('input', () => this.onSearchInput(), false);
if (this.wanakanaEnable !== null) {
@ -69,34 +66,26 @@ class DisplaySearch extends Display {
this.wanakanaEnable.checked = false;
}
this.wanakanaEnable.addEventListener('change', (e) => {
const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
const {queryParams: {query=''}} = parseUrl(window.location.href);
if (e.target.checked) {
window.wanakana.bind(this.query);
this.setQuery(window.wanakana.toKana(query));
apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext());
} else {
window.wanakana.unbind(this.query);
this.setQuery(query);
apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext());
}
this.setQuery(query);
this.onSearchQueryUpdated(this.query.value, false);
});
}
const query = DisplaySearch.getSearchQueryFromLocation(window.location.href);
if (query !== null) {
if (this.isWanakanaEnabled()) {
this.setQuery(window.wanakana.toKana(query));
} else {
this.setQuery(query);
}
this.onSearchQueryUpdated(this.query.value, false);
}
this.setQuery(query);
this.onSearchQueryUpdated(this.query.value, false);
}
if (this.clipboardMonitorEnable !== null) {
if (this.clipboardMonitorEnable !== null && mode !== 'popup') {
if (this.options.general.enableClipboardMonitor === true) {
this.clipboardMonitorEnable.checked = true;
this.startClipboardMonitor();
this.clipboardMonitor.start();
} else {
this.clipboardMonitorEnable.checked = false;
}
@ -106,7 +95,7 @@ class DisplaySearch extends Display {
{permissions: ['clipboardRead']},
(granted) => {
if (granted) {
this.startClipboardMonitor();
this.clipboardMonitor.start();
apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext());
} else {
e.target.checked = false;
@ -114,16 +103,20 @@ class DisplaySearch extends Display {
}
);
} else {
this.stopClipboardMonitor();
this.clipboardMonitor.stop();
apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext());
}
});
}
chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
window.addEventListener('popstate', (e) => this.onPopState(e));
window.addEventListener('copy', (e) => this.onCopy(e));
this.clipboardMonitor.onClipboardText = (text) => this.onExternalSearchUpdate(text);
this.updateSearchButton();
this.initClipboardMonitor();
} catch (e) {
this.onError(e);
}
@ -159,25 +152,32 @@ class DisplaySearch extends Display {
e.preventDefault();
const query = this.query.value;
this.queryParser.setText(query);
const queryString = query.length > 0 ? `?query=${encodeURIComponent(query)}` : '';
window.history.pushState(null, '', `${window.location.pathname}${queryString}`);
const url = new URL(window.location.href);
url.searchParams.set('query', query);
window.history.pushState(null, '', url.toString());
this.onSearchQueryUpdated(query, true);
}
onPopState() {
const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
if (this.query !== null) {
if (this.isWanakanaEnabled()) {
this.setQuery(window.wanakana.toKana(query));
} else {
this.setQuery(query);
}
}
const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
document.documentElement.dataset.searchMode = mode;
this.setQuery(query);
this.onSearchQueryUpdated(this.query.value, false);
}
onRuntimeMessage({action, params}, sender, callback) {
const handler = DisplaySearch._runtimeMessageHandlers.get(action);
if (typeof handler !== 'function') { return false; }
const result = handler(this, params, sender);
callback(result);
return false;
}
onKeyDown(e) {
const key = Display.getKeyFromEvent(e);
const ignoreKeys = DisplaySearch.onKeyDownIgnoreKeys;
@ -202,6 +202,19 @@ class DisplaySearch extends Display {
}
}
onCopy() {
// ignore copy from search page
this.clipboardMonitor.setPreviousText(document.getSelection().toString().trim());
}
onExternalSearchUpdate(text) {
this.setQuery(text);
const url = new URL(window.location.href);
url.searchParams.set('query', text);
window.history.pushState(null, '', url.toString());
this.onSearchQueryUpdated(this.query.value, true);
}
async onSearchQueryUpdated(query, animate) {
try {
const details = {};
@ -241,74 +254,6 @@ class DisplaySearch extends Display {
this.queryParser.setOptions(this.options);
}
initClipboardMonitor() {
// ignore copy from search page
window.addEventListener('copy', () => {
this.clipboardPreviousText = document.getSelection().toString().trim();
});
}
startClipboardMonitor() {
// The token below is used as a unique identifier to ensure that a new clipboard monitor
// hasn't been started during the await call. The check below the await this.getClipboardText()
// call will exit early if the reference has changed.
const token = {};
const intervalCallback = async () => {
this.clipboardMonitorTimerId = null;
let text = await this.getClipboardText();
if (this.clipboardMonitorTimerToken !== token) { return; }
if (
typeof text === 'string' &&
(text = text.trim()).length > 0 &&
text !== this.clipboardPreviousText
) {
this.clipboardPreviousText = text;
if (jpIsStringPartiallyJapanese(text)) {
this.setQuery(this.isWanakanaEnabled() ? window.wanakana.toKana(text) : text);
window.history.pushState(null, '', `${window.location.pathname}?query=${encodeURIComponent(text)}`);
this.onSearchQueryUpdated(this.query.value, true);
}
}
this.clipboardMonitorTimerId = setTimeout(intervalCallback, this.clipboardInterval);
};
this.clipboardMonitorTimerToken = token;
intervalCallback();
}
stopClipboardMonitor() {
this.clipboardMonitorTimerToken = null;
if (this.clipboardMonitorTimerId !== null) {
clearTimeout(this.clipboardMonitorTimerId);
this.clipboardMonitorTimerId = null;
}
}
async getClipboardText() {
/*
Notes:
apiClipboardGet doesn't work on Firefox because document.execCommand('paste')
results in an empty string on the web extension background page.
This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
Therefore, navigator.clipboard.readText() is used on Firefox.
navigator.clipboard.readText() can't be used in Chrome for two reasons:
* Requires page to be focused, else it rejects with an exception.
* When the page is focused, Chrome will request clipboard permission, despite already
being an extension with clipboard permissions. It effectively asks for the
non-extension permission for clipboard access.
*/
try {
return this.isFirefox ? await navigator.clipboard.readText() : await apiClipboardGet();
} catch (e) {
return null;
}
}
isWanakanaEnabled() {
return this.wanakanaEnable !== null && this.wanakanaEnable.checked;
}
@ -318,8 +263,9 @@ class DisplaySearch extends Display {
}
setQuery(query) {
this.query.value = query;
this.queryParser.setText(query);
const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query;
this.query.value = interpretedQuery;
this.queryParser.setText(interpretedQuery);
}
setIntroVisible(visible, animate) {
@ -394,22 +340,6 @@ class DisplaySearch extends Display {
document.title = `${text} - Yomichan Search`;
}
}
static getSearchQueryFromLocation(url) {
const match = /^[^?#]*\?(?:[^&#]*&)?query=([^&#]*)/.exec(url);
return match !== null ? decodeURIComponent(match[1]) : null;
}
static async _isFirefox() {
const {browser} = await apiGetEnvironmentInfo();
switch (browser) {
case 'firefox':
case 'firefox-mobile':
return true;
default:
return false;
}
}
}
DisplaySearch.onKeyDownIgnoreKeys = {
@ -427,4 +357,8 @@ DisplaySearch.onKeyDownIgnoreKeys = {
'Shift': []
};
DisplaySearch._runtimeMessageHandlers = new Map([
['searchQueryUpdate', (self, {query}) => { self.onExternalSearchUpdate(query); }]
]);
DisplaySearch.instance = DisplaySearch.create();

View File

@ -28,6 +28,22 @@ function getOptionsFullMutable() {
async function formRead(options) {
options.general.enable = $('#enable').prop('checked');
const enableClipboardPopups = $('#enable-clipboard-popups').prop('checked');
if (enableClipboardPopups) {
options.general.enableClipboardPopups = await new Promise((resolve, _reject) => {
chrome.permissions.request(
{permissions: ['clipboardRead']},
(granted) => {
if (!granted) {
$('#enable-clipboard-popups').prop('checked', false);
}
resolve(granted);
}
);
});
} else {
options.general.enableClipboardPopups = false;
}
options.general.showGuide = $('#show-usage-guide').prop('checked');
options.general.compactTags = $('#compact-tags').prop('checked');
options.general.compactGlossaries = $('#compact-glossaries').prop('checked');
@ -104,6 +120,7 @@ async function formRead(options) {
async function formWrite(options) {
$('#enable').prop('checked', options.general.enable);
$('#enable-clipboard-popups').prop('checked', options.general.enableClipboardPopups);
$('#show-usage-guide').prop('checked', options.general.showGuide);
$('#compact-tags').prop('checked', options.general.compactTags);
$('#compact-glossaries').prop('checked', options.general.compactGlossaries);

View File

@ -25,23 +25,25 @@
<p style="margin-bottom: 0;">Search your installed dictionaries by entering a Japanese expression into the field below.</p>
</div>
<div class="input-group" style="padding-top: 20px;">
<span title="Enable kana input method" class="input-group-text">
<input type="checkbox" id="wanakana-enable" class="icon-checkbox" />
<label for="wanakana-enable" class="scan-disable"></label>
</span>
<span title="Enable clipboard monitor" class="input-group-text">
<input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" />
<label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></label>
</span>
</div>
<div class="search-input">
<div class="input-group" style="padding-top: 20px;">
<span title="Enable kana input method" class="input-group-text">
<input type="checkbox" id="wanakana-enable" class="icon-checkbox" />
<label for="wanakana-enable" class="scan-disable"></label>
</span>
<span title="Enable clipboard monitor" class="input-group-text">
<input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" />
<label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></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">
</span>
</form>
<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">
</span>
</form>
</div>
<div id="spinner" hidden><img src="/mixed/img/spinner.gif"></div>
@ -87,6 +89,7 @@
<script src="/mixed/js/text-scanner.js"></script>
<script src="/bg/js/search-query-parser.js"></script>
<script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/search.js"></script>
<script src="/bg/js/search-frontend.js"></script>
</body>

View File

@ -134,6 +134,10 @@
<label><input type="checkbox" id="enable"> Enable content scanning</label>
</div>
<div class="checkbox" data-hide-for-browser="firefox-mobile">
<label><input type="checkbox" id="enable-clipboard-popups"> Enable native popups when copying Japanese text</label>
</div>
<div class="checkbox">
<label><input type="checkbox" id="show-usage-guide"> Show usage guide on startup</label>
</div>

View File

@ -136,6 +136,10 @@ html:root[data-yomichan-page=float] .navigation-header:not([hidden])~.navigation
margin-right: 0.2em;
}
html:root[data-yomichan-page=search][data-search-mode=popup] .search-input {
display: none;
}
/*
* Entries

View File

@ -128,6 +128,14 @@ function stringReverse(string) {
return string.split('').reverse().join('').replace(/([\uDC00-\uDFFF])([\uD800-\uDBFF])/g, '$2$1');
}
function parseUrl(url) {
const parsedUrl = new URL(url);
const baseUrl = `${parsedUrl.origin}${parsedUrl.pathname}`;
const queryParams = Array.from(parsedUrl.searchParams.entries())
.reduce((a, [k, v]) => Object.assign({}, a, {[k]: v}), {});
return {baseUrl, queryParams};
}
/*
* Async utilities