summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsiikamiika <siikamiika@users.noreply.github.com>2019-09-05 09:25:42 +0300
committerGitHub <noreply@github.com>2019-09-05 09:25:42 +0300
commit9cd0101b62fe99d736b4e6b9072c2aa4827311af (patch)
tree822811aae7528487bc4ebb17d363cdb4b7b0b00c
parent4ac55da7dd5354e6c3495f04583352d0d863b7b6 (diff)
parent9028b55774f788f0b61acadb8d3ba85b2bfab34a (diff)
Merge pull request #185 from toasted-nutbread/recursive-popups
Recursive popups
-rw-r--r--ext/bg/background.html1
-rw-r--r--ext/bg/js/api.js5
-rw-r--r--ext/bg/js/backend-api-forwarder.js45
-rw-r--r--ext/bg/js/backend.js6
-rw-r--r--ext/bg/js/options.js5
-rw-r--r--ext/bg/js/search-frontend.js51
-rw-r--r--ext/bg/js/settings.js6
-rw-r--r--ext/bg/search.html1
-rw-r--r--ext/bg/settings.html13
-rw-r--r--ext/fg/float.html2
-rw-r--r--ext/fg/js/api.js4
-rw-r--r--ext/fg/js/float.js4
-rw-r--r--ext/fg/js/frontend-api-receiver.js62
-rw-r--r--ext/fg/js/frontend-api-sender.js127
-rw-r--r--ext/fg/js/frontend.js64
-rw-r--r--ext/fg/js/popup-nested.js51
-rw-r--r--ext/fg/js/popup-proxy-host.js134
-rw-r--r--ext/fg/js/popup-proxy.js113
-rw-r--r--ext/fg/js/popup.js112
-rw-r--r--ext/fg/js/source.js44
-rw-r--r--ext/fg/js/util.js6
-rw-r--r--ext/manifest.json2
22 files changed, 814 insertions, 44 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html
index 5978f10f..90a56024 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -15,6 +15,7 @@
<script src="/bg/js/anki.js"></script>
<script src="/bg/js/api.js"></script>
<script src="/bg/js/audio.js"></script>
+ <script src="/bg/js/backend-api-forwarder.js"></script>
<script src="/bg/js/database.js"></script>
<script src="/bg/js/deinflector.js"></script>
<script src="/bg/js/dictionary.js"></script>
diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js
index 4b2bacd7..b8ef4362 100644
--- a/ext/bg/js/api.js
+++ b/ext/bg/js/api.js
@@ -205,3 +205,8 @@ function apiForward(action, params, sender) {
chrome.tabs.sendMessage(tabId, {action, params}, (response) => resolve(response));
});
}
+
+function apiFrameInformationGet(sender) {
+ const frameId = sender.frameId;
+ return Promise.resolve({frameId});
+}
diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js
new file mode 100644
index 00000000..979afd16
--- /dev/null
+++ b/ext/bg/js/backend-api-forwarder.js
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+class BackendApiForwarder {
+ constructor() {
+ chrome.runtime.onConnect.addListener(this.onConnect.bind(this));
+ }
+
+ onConnect(port) {
+ if (port.name !== 'backend-api-forwarder') { return; }
+
+ let tabId;
+ if (!(
+ port.sender &&
+ port.sender.tab &&
+ (typeof (tabId = port.sender.tab.id)) === 'number'
+ )) {
+ port.disconnect();
+ return;
+ }
+
+ const forwardPort = chrome.tabs.connect(tabId, {name: 'frontend-api-receiver'});
+
+ port.onMessage.addListener(message => forwardPort.postMessage(message));
+ forwardPort.onMessage.addListener(message => port.postMessage(message));
+ port.onDisconnect.addListener(() => forwardPort.disconnect());
+ forwardPort.onDisconnect.addListener(() => port.disconnect());
+ }
+}
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index d95cb82d..39fd4288 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -22,6 +22,8 @@ class Backend {
this.translator = new Translator();
this.anki = new AnkiNull();
this.options = null;
+
+ this.apiForwarder = new BackendApiForwarder();
}
async prepare() {
@@ -125,6 +127,10 @@ class Backend {
forward: ({action, params}) => {
forward(apiForward(action, params, sender), callback);
+ },
+
+ frameInformationGet: () => {
+ forward(apiFrameInformationGet(sender), callback);
}
};
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index 7d993987..df95aae9 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -219,7 +219,10 @@ function optionsSetDefaults(options) {
delay: 20,
length: 10,
modifier: 'shift',
- deepDomScan: false
+ deepDomScan: false,
+ popupNestingMaxDepth: 0,
+ enableOnPopupExpressions: false,
+ enableOnSearchPage: true
},
dictionaries: {},
diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js
new file mode 100644
index 00000000..840a1ea8
--- /dev/null
+++ b/ext/bg/js/search-frontend.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+async function searchFrontendSetup() {
+ const options = await apiOptionsGet();
+ if (!options.scanning.enableOnSearchPage) { return; }
+
+ const scriptSrcs = [
+ '/fg/js/api.js',
+ '/fg/js/frontend-api-receiver.js',
+ '/fg/js/popup.js',
+ '/fg/js/util.js',
+ '/fg/js/popup-proxy-host.js',
+ '/fg/js/frontend.js'
+ ];
+ for (const src of scriptSrcs) {
+ const script = document.createElement('script');
+ script.async = false;
+ script.src = src;
+ document.body.appendChild(script);
+ }
+
+ const styleSrcs = [
+ '/fg/css/client.css'
+ ];
+ for (const src of styleSrcs) {
+ const style = document.createElement('link');
+ style.rel = 'stylesheet';
+ style.type = 'text/css';
+ style.href = src;
+ document.head.appendChild(style);
+ }
+}
+
+searchFrontendSetup();
diff --git a/ext/bg/js/settings.js b/ext/bg/js/settings.js
index f5d669b2..83f4528c 100644
--- a/ext/bg/js/settings.js
+++ b/ext/bg/js/settings.js
@@ -48,9 +48,12 @@ async function formRead() {
optionsNew.scanning.alphanumeric = $('#search-alphanumeric').prop('checked');
optionsNew.scanning.autoHideResults = $('#auto-hide-results').prop('checked');
optionsNew.scanning.deepDomScan = $('#deep-dom-scan').prop('checked');
+ optionsNew.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked');
+ optionsNew.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked');
optionsNew.scanning.delay = parseInt($('#scan-delay').val(), 10);
optionsNew.scanning.length = parseInt($('#scan-length').val(), 10);
optionsNew.scanning.modifier = $('#scan-modifier-key').val();
+ optionsNew.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10);
optionsNew.anki.enable = $('#anki-enable').prop('checked');
optionsNew.anki.tags = $('#card-tags').val().split(/[,; ]+/);
@@ -189,9 +192,12 @@ async function onReady() {
$('#search-alphanumeric').prop('checked', options.scanning.alphanumeric);
$('#auto-hide-results').prop('checked', options.scanning.autoHideResults);
$('#deep-dom-scan').prop('checked', options.scanning.deepDomScan);
+ $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions);
+ $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage);
$('#scan-delay').val(options.scanning.delay);
$('#scan-length').val(options.scanning.length);
$('#scan-modifier-key').val(options.scanning.modifier);
+ $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth);
$('#dict-purge-link').click(utilAsync(onDictionaryPurge));
$('#dict-file').change(utilAsync(onDictionaryImport));
diff --git a/ext/bg/search.html b/ext/bg/search.html
index 05c0daab..38c5a4e9 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -51,5 +51,6 @@
<script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/search.js"></script>
+ <script src="/bg/js/search-frontend.js"></script>
</body>
</html>
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index cc140023..85b7ee5f 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -193,6 +193,14 @@
</div>
<div class="checkbox options-advanced">
+ <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of popup expressions</label>
+ </div>
+
+ <div class="checkbox">
+ <label><input type="checkbox" id="enable-scanning-on-search-page"> Enable scanning on search page</label>
+ </div>
+
+ <div class="checkbox options-advanced">
<label><input type="checkbox" id="deep-dom-scan"> Deep DOM scan</label>
</div>
@@ -215,6 +223,11 @@
<option value="shift">Shift</option>
</select>
</div>
+
+ <div class="form-group options-advanced">
+ <label for="popup-nesting-max-depth">Maximum nested popup depth</label>
+ <input type="number" min="0" id="popup-nesting-max-depth" class="form-control">
+ </div>
</div>
<div>
diff --git a/ext/fg/float.html b/ext/fg/float.html
index 0133e653..465db589 100644
--- a/ext/fg/float.html
+++ b/ext/fg/float.html
@@ -43,5 +43,7 @@
<script src="/mixed/js/display.js"></script>
<script src="/fg/js/float.js"></script>
+
+ <script src="/fg/js/popup-nested.js"></script>
</body>
</html>
diff --git a/ext/fg/js/api.js b/ext/fg/js/api.js
index 99ad307c..6bcb0dbb 100644
--- a/ext/fg/js/api.js
+++ b/ext/fg/js/api.js
@@ -64,3 +64,7 @@ function apiScreenshotGet(options) {
function apiForward(action, params) {
return utilInvoke('forward', {action, params});
}
+
+function apiFrameInformationGet() {
+ return utilInvoke('frameInformationGet');
+}
diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js
index c0ec8a15..3c521714 100644
--- a/ext/fg/js/float.js
+++ b/ext/fg/js/float.js
@@ -72,6 +72,10 @@ class DisplayFloat extends Display {
if (css) {
this.setStyle(css);
}
+ },
+
+ popupNestedInitialize: ({id, depth, parentFrameId}) => {
+ popupNestedInitialize(id, depth, parentFrameId);
}
};
diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js
new file mode 100644
index 00000000..687e5c3c
--- /dev/null
+++ b/ext/fg/js/frontend-api-receiver.js
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+class FrontendApiReceiver {
+ constructor(source='', handlers={}) {
+ this.source = source;
+ this.handlers = handlers;
+
+ chrome.runtime.onConnect.addListener(this.onConnect.bind(this));
+ }
+
+ onConnect(port) {
+ if (port.name !== 'frontend-api-receiver') { return; }
+
+ port.onMessage.addListener(this.onMessage.bind(this, port));
+ }
+
+ onMessage(port, {id, action, params, target, senderId}) {
+ if (
+ target !== this.source ||
+ !this.handlers.hasOwnProperty(action)
+ ) {
+ return;
+ }
+
+ this.sendAck(port, id, senderId);
+
+ const handler = this.handlers[action];
+ handler(params).then(
+ result => {
+ this.sendResult(port, id, senderId, {result});
+ },
+ e => {
+ const error = typeof e.toString === 'function' ? e.toString() : e;
+ this.sendResult(port, id, senderId, {error});
+ });
+ }
+
+ sendAck(port, id, senderId) {
+ port.postMessage({type: 'ack', id, senderId});
+ }
+
+ sendResult(port, id, senderId, data) {
+ port.postMessage({type: 'result', id, senderId, data});
+ }
+}
diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js
new file mode 100644
index 00000000..a1cb02c4
--- /dev/null
+++ b/ext/fg/js/frontend-api-sender.js
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+class FrontendApiSender {
+ constructor() {
+ this.senderId = FrontendApiSender.generateId(16);
+ this.ackTimeout = 3000; // 3 seconds
+ this.responseTimeout = 10000; // 10 seconds
+ this.callbacks = {};
+ this.disconnected = false;
+ this.nextId = 0;
+
+ this.port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'});
+ this.port.onDisconnect.addListener(this.onDisconnect.bind(this));
+ this.port.onMessage.addListener(this.onMessage.bind(this));
+ }
+
+ invoke(action, params, target) {
+ if (this.disconnected) {
+ return Promise.reject('Disconnected');
+ }
+
+ const id = `${this.nextId}`;
+ ++this.nextId;
+
+ return new Promise((resolve, reject) => {
+ const info = {id, resolve, reject, ack: false, timer: null};
+ this.callbacks[id] = info;
+ info.timer = setTimeout(() => this.onError(id, 'Timeout (ack)'), this.ackTimeout);
+
+ this.port.postMessage({id, action, params, target, senderId: this.senderId});
+ });
+ }
+
+ onMessage({type, id, data, senderId}) {
+ if (senderId !== this.senderId) { return; }
+ switch (type) {
+ case 'ack':
+ this.onAck(id);
+ break;
+ case 'result':
+ this.onResult(id, data);
+ break;
+ }
+ }
+
+ onDisconnect() {
+ this.disconnected = true;
+
+ const ids = Object.keys(this.callbacks);
+ for (const id of ids) {
+ this.onError(id, 'Disconnected');
+ }
+ }
+
+ onAck(id) {
+ if (!this.callbacks.hasOwnProperty(id)) {
+ console.warn(`ID ${id} not found for ack`);
+ return;
+ }
+
+ const info = this.callbacks[id];
+ if (info.ack) {
+ console.warn(`Request ${id} already ack'd`);
+ return;
+ }
+
+ info.ack = true;
+ clearTimeout(info.timer);
+ info.timer = setTimeout(() => this.onError(id, 'Timeout (response)'), this.responseTimeout);
+ }
+
+ onResult(id, data) {
+ if (!this.callbacks.hasOwnProperty(id)) {
+ console.warn(`ID ${id} not found`);
+ return;
+ }
+
+ const info = this.callbacks[id];
+ if (!info.ack) {
+ console.warn(`Request ${id} not ack'd`);
+ return;
+ }
+
+ delete this.callbacks[id];
+ clearTimeout(info.timer);
+ info.timer = null;
+
+ if (typeof data.error === 'string') {
+ info.reject(data.error);
+ } else {
+ info.resolve(data.result);
+ }
+ }
+
+ onError(id, reason) {
+ if (!this.callbacks.hasOwnProperty(id)) { return; }
+ const info = this.callbacks[id];
+ delete this.callbacks[id];
+ info.timer = null;
+ info.reject(reason);
+ }
+
+ static generateId(length) {
+ let id = '';
+ for (let i = 0; i < length; ++i) {
+ id += Math.floor(Math.random() * 256).toString(16).padStart(2, '0');
+ }
+ return id;
+ }
+}
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
index 8a5c48d0..b70bf036 100644
--- a/ext/fg/js/frontend.js
+++ b/ext/fg/js/frontend.js
@@ -18,14 +18,15 @@
class Frontend {
- constructor() {
- this.popup = new Popup();
+ constructor(popup, ignoreNodes) {
+ this.popup = popup;
this.popupTimer = null;
this.mouseDownLeft = false;
this.mouseDownMiddle = false;
this.textSourceLast = null;
this.pendingLookup = false;
this.options = null;
+ this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null);
this.primaryTouchIdentifier = null;
this.contextMenuChecking = false;
@@ -36,6 +37,17 @@ class Frontend {
this.scrollPrevent = false;
}
+ static create() {
+ const initializationData = window.frontendInitializationData;
+ const isNested = (initializationData !== null && typeof initializationData === 'object');
+ const {id, parentFrameId, ignoreNodes} = isNested ? initializationData : {};
+
+ const popup = isNested ? new PopupProxy(id, parentFrameId) : PopupProxyHost.instance.createPopup(null);
+ const frontend = new Frontend(popup, ignoreNodes);
+ frontend.prepare();
+ return frontend;
+ }
+
async prepare() {
try {
this.options = await apiOptionsGet();
@@ -44,6 +56,7 @@ class Frontend {
window.addEventListener('mousedown', this.onMouseDown.bind(this));
window.addEventListener('mousemove', this.onMouseMove.bind(this));
window.addEventListener('mouseover', this.onMouseOver.bind(this));
+ window.addEventListener('mouseout', this.onMouseOut.bind(this));
window.addEventListener('mouseup', this.onMouseUp.bind(this));
window.addEventListener('resize', this.onResize.bind(this));
@@ -137,6 +150,10 @@ class Frontend {
}
}
+ onMouseOut(e) {
+ this.popupTimerClear();
+ }
+
onFrameMessage(e) {
const handlers = {
popupClose: () => {
@@ -259,9 +276,8 @@ class Frontend {
const handler = handlers[action];
if (handler) {
handler(params);
+ callback();
}
-
- callback();
}
onError(error) {
@@ -281,7 +297,7 @@ class Frontend {
}
async searchAt(point, type) {
- if (this.pendingLookup || this.popup.containsPoint(point)) {
+ if (this.pendingLookup || await this.popup.containsPoint(point)) {
return;
}
@@ -324,9 +340,14 @@ class Frontend {
}
async searchTerms(textSource, focus) {
- textSource.setEndOffset(this.options.scanning.length);
+ this.setTextSourceScanLength(textSource, this.options.scanning.length);
- const {definitions, length} = await apiTermsFind(textSource.text());
+ const searchText = textSource.text();
+ if (searchText.length === 0) {
+ return;
+ }
+
+ const {definitions, length} = await apiTermsFind(searchText);
if (definitions.length === 0) {
return false;
}
@@ -352,9 +373,14 @@ class Frontend {
}
async searchKanji(textSource, focus) {
- textSource.setEndOffset(1);
+ this.setTextSourceScanLength(textSource, 1);
- const definitions = await apiKanjiFind(textSource.text());
+ const searchText = textSource.text();
+ if (searchText.length === 0) {
+ return;
+ }
+
+ const definitions = await apiKanjiFind(searchText);
if (definitions.length === 0) {
return false;
}
@@ -480,7 +506,23 @@ class Frontend {
}
return false;
}
+
+ setTextSourceScanLength(textSource, length) {
+ textSource.setEndOffset(length);
+ if (this.ignoreNodes === null || !textSource.range) {
+ return;
+ }
+
+ length = textSource.text().length;
+ while (textSource.range && length > 0) {
+ const nodes = TextSourceRange.getNodesInRange(textSource.range);
+ if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) {
+ break;
+ }
+ --length;
+ textSource.setEndOffset(length);
+ }
+ }
}
-window.yomichan_frontend = new Frontend();
-window.yomichan_frontend.prepare();
+window.yomichan_frontend = Frontend.create();
diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js
new file mode 100644
index 00000000..e0376bb2
--- /dev/null
+++ b/ext/fg/js/popup-nested.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+let popupNestedInitialized = false;
+
+async function popupNestedInitialize(id, depth, parentFrameId) {
+ if (popupNestedInitialized) {
+ return;
+ }
+ popupNestedInitialized = true;
+
+ const options = await apiOptionsGet();
+ const popupNestingMaxDepth = options.scanning.popupNestingMaxDepth;
+
+ if (!(typeof popupNestingMaxDepth === 'number' && typeof depth === 'number' && depth < popupNestingMaxDepth)) {
+ return;
+ }
+
+ const ignoreNodes = options.scanning.enableOnPopupExpressions ? [] : [ '.expression', '.expression *' ];
+
+ window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes};
+
+ const scriptSrcs = [
+ '/fg/js/frontend-api-sender.js',
+ '/fg/js/popup.js',
+ '/fg/js/popup-proxy.js',
+ '/fg/js/frontend.js'
+ ];
+ for (const src of scriptSrcs) {
+ const script = document.createElement('script');
+ script.async = false;
+ script.src = src;
+ document.body.appendChild(script);
+ }
+}
diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js
new file mode 100644
index 00000000..fa61aeb4
--- /dev/null
+++ b/ext/fg/js/popup-proxy-host.js
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+class PopupProxyHost {
+ constructor() {
+ this.popups = {};
+ this.nextId = 0;
+ this.apiReceiver = null;
+ this.frameIdPromise = null;
+ }
+
+ static create() {
+ const popupProxyHost = new PopupProxyHost();
+ popupProxyHost.prepare();
+ return popupProxyHost;
+ }
+
+ async prepare() {
+ this.frameIdPromise = apiFrameInformationGet();
+ const {frameId} = await this.frameIdPromise;
+ if (typeof frameId !== 'number') { return; }
+
+ this.apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, {
+ createNestedPopup: ({parentId}) => this.createNestedPopup(parentId),
+ show: ({id, elementRect, options}) => this.show(id, elementRect, options),
+ showOrphaned: ({id, elementRect, options}) => this.show(id, elementRect, options),
+ hide: ({id}) => this.hide(id),
+ setVisible: ({id, visible}) => this.setVisible(id, visible),
+ containsPoint: ({id, point}) => this.containsPoint(id, point),
+ termsShow: ({id, elementRect, definitions, options, context}) => this.termsShow(id, elementRect, definitions, options, context),
+ kanjiShow: ({id, elementRect, definitions, options, context}) => this.kanjiShow(id, elementRect, definitions, options, context),
+ clearAutoPlayTimer: ({id}) => this.clearAutoPlayTimer(id)
+ });
+ }
+
+ createPopup(parentId) {
+ const parent = (typeof parentId === 'string' && this.popups.hasOwnProperty(parentId) ? this.popups[parentId] : null);
+ const depth = (parent !== null ? parent.depth + 1 : 0);
+ const id = `${this.nextId}`;
+ ++this.nextId;
+ const popup = new Popup(id, depth, this.frameIdPromise);
+ if (parent !== null) {
+ popup.parent = parent;
+ parent.child = popup;
+ }
+ this.popups[id] = popup;
+ return popup;
+ }
+
+ async createNestedPopup(parentId) {
+ return this.createPopup(parentId).id;
+ }
+
+ getPopup(id) {
+ if (!this.popups.hasOwnProperty(id)) {
+ throw 'Invalid popup ID';
+ }
+
+ return this.popups[id];
+ }
+
+ jsonRectToDOMRect(popup, jsonRect) {
+ let x = jsonRect.x;
+ let y = jsonRect.y;
+ if (popup.parent !== null) {
+ const popupRect = popup.parent.container.getBoundingClientRect();
+ x += popupRect.x;
+ y += popupRect.y;
+ }
+ return new DOMRect(x, y, jsonRect.width, jsonRect.height);
+ }
+
+ async show(id, elementRect, options) {
+ const popup = this.getPopup(id);
+ elementRect = this.jsonRectToDOMRect(popup, elementRect);
+ return await popup.show(elementRect, options);
+ }
+
+ async showOrphaned(id, elementRect, options) {
+ const popup = this.getPopup(id);
+ elementRect = this.jsonRectToDOMRect(popup, elementRect);
+ return await popup.showOrphaned(elementRect, options);
+ }
+
+ async hide(id) {
+ const popup = this.getPopup(id);
+ return popup.hide();
+ }
+
+ async setVisible(id, visible) {
+ const popup = this.getPopup(id);
+ return popup.setVisible(visible);
+ }
+
+ async containsPoint(id, point) {
+ const popup = this.getPopup(id);
+ return await popup.containsPoint(point);
+ }
+
+ async termsShow(id, elementRect, definitions, options, context) {
+ const popup = this.getPopup(id);
+ elementRect = this.jsonRectToDOMRect(popup, elementRect);
+ return await popup.termsShow(elementRect, definitions, options, context);
+ }
+
+ async kanjiShow(id, elementRect, definitions, options, context) {
+ const popup = this.getPopup(id);
+ elementRect = this.jsonRectToDOMRect(popup, elementRect);
+ return await popup.kanjiShow(elementRect, definitions, options, context);
+ }
+
+ async clearAutoPlayTimer(id) {
+ const popup = this.getPopup(id);
+ return popup.clearAutoPlayTimer();
+ }
+}
+
+PopupProxyHost.instance = PopupProxyHost.create();
diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js
new file mode 100644
index 00000000..f6295079
--- /dev/null
+++ b/ext/fg/js/popup-proxy.js
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+class PopupProxy {
+ constructor(parentId, parentFrameId) {
+ this.parentId = parentId;
+ this.parentFrameId = parentFrameId;
+ this.id = null;
+ this.idPromise = null;
+ this.parent = null;
+ this.child = null;
+ this.depth = 0;
+
+ this.container = null;
+
+ this.apiSender = new FrontendApiSender();
+ }
+
+ getPopupId() {
+ if (this.idPromise === null) {
+ this.idPromise = this.getPopupIdAsync();
+ }
+ return this.idPromise;
+ }
+
+ async getPopupIdAsync() {
+ const id = await this.invokeHostApi('createNestedPopup', {parentId: this.parentId});
+ this.id = id;
+ return id;
+ }
+
+ async show(elementRect, options) {
+ const id = await this.getPopupId();
+ elementRect = PopupProxy.DOMRectToJson(elementRect);
+ return await this.invokeHostApi('show', {id, elementRect, options});
+ }
+
+ async showOrphaned(elementRect, options) {
+ const id = await this.getPopupId();
+ elementRect = PopupProxy.DOMRectToJson(elementRect);
+ return await this.invokeHostApi('showOrphaned', {id, elementRect, options});
+ }
+
+ async hide() {
+ if (this.id === null) {
+ return;
+ }
+ return await this.invokeHostApi('hide', {id: this.id});
+ }
+
+ async setVisible(visible) {
+ const id = await this.getPopupId();
+ return await this.invokeHostApi('setVisible', {id, visible});
+ }
+
+ async containsPoint(point) {
+ if (this.id === null) {
+ return false;
+ }
+ return await this.invokeHostApi('containsPoint', {id: this.id, point});
+ }
+
+ async termsShow(elementRect, definitions, options, context) {
+ const id = await this.getPopupId();
+ elementRect = PopupProxy.DOMRectToJson(elementRect);
+ return await this.invokeHostApi('termsShow', {id, elementRect, definitions, options, context});
+ }
+
+ async kanjiShow(elementRect, definitions, options, context) {
+ const id = await this.getPopupId();
+ elementRect = PopupProxy.DOMRectToJson(elementRect);
+ return await this.invokeHostApi('kanjiShow', {id, elementRect, definitions, options, context});
+ }
+
+ async clearAutoPlayTimer() {
+ if (this.id === null) {
+ return;
+ }
+ return await this.invokeHostApi('clearAutoPlayTimer', {id: this.id});
+ }
+
+ invokeHostApi(action, params={}) {
+ if (typeof this.parentFrameId !== 'number') {
+ return Promise.reject('Invalid frame');
+ }
+ return this.apiSender.invoke(action, params, `popup-proxy-host#${this.parentFrameId}`);
+ }
+
+ static DOMRectToJson(domRect) {
+ return {
+ x: domRect.x,
+ y: domRect.y,
+ width: domRect.width,
+ height: domRect.height
+ };
+ }
+}
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
index 86ce575d..1b15977b 100644
--- a/ext/fg/js/popup.js
+++ b/ext/fg/js/popup.js
@@ -18,7 +18,13 @@
class Popup {
- constructor() {
+ constructor(id, depth, frameIdPromise) {
+ this.id = id;
+ this.depth = depth;
+ this.frameIdPromise = frameIdPromise;
+ this.frameId = null;
+ this.parent = null;
+ this.child = null;
this.container = document.createElement('iframe');
this.container.id = 'yomichan-float';
this.container.addEventListener('mousedown', e => e.stopPropagation());
@@ -26,26 +32,46 @@ class Popup {
this.container.setAttribute('src', chrome.extension.getURL('/fg/float.html'));
this.container.style.width = '0px';
this.container.style.height = '0px';
- this.injected = null;
+ this.injectPromise = null;
+ this.isInjected = false;
}
inject(options) {
- if (!this.injected) {
- this.injected = new Promise((resolve, reject) => {
- this.container.addEventListener('load', () => {
- this.invokeApi('setOptions', {
- general: {
- customPopupCss: options.general.customPopupCss
- }
- });
- resolve();
- });
- this.observeFullscreen();
- this.onFullscreenChanged();
- });
+ if (this.injectPromise === null) {
+ this.injectPromise = this.createInjectPromise(options);
+ }
+ return this.injectPromise;
+ }
+
+ async createInjectPromise(options) {
+ try {
+ const {frameId} = await this.frameIdPromise;
+ if (typeof frameId === 'number') {
+ this.frameId = frameId;
+ }
+ } catch (e) {
+ // NOP
}
- return this.injected;
+ return new Promise((resolve) => {
+ const parentFrameId = (typeof this.frameId === 'number' ? this.frameId : null);
+ this.container.addEventListener('load', () => {
+ this.invokeApi('popupNestedInitialize', {
+ id: this.id,
+ depth: this.depth,
+ parentFrameId
+ });
+ this.invokeApi('setOptions', {
+ general: {
+ customPopupCss: options.general.customPopupCss
+ }
+ });
+ resolve();
+ });
+ this.observeFullscreen();
+ this.onFullscreenChanged();
+ this.isInjected = true;
+ });
}
async show(elementRect, writingMode, options) {
@@ -77,6 +103,8 @@ class Popup {
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.visibility = 'visible';
+
+ this.hideChildren();
}
static getPositionForHorizontalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral) {
@@ -178,12 +206,28 @@ class Popup {
}
hide() {
+ this.hideChildren();
+ this.hideContainer();
+ this.focusParent();
+ }
+
+ hideChildren() {
+ // recursively hides all children
+ if (this.child && !this.child.isContainerHidden()) {
+ this.child.hide();
+ }
+ }
+
+ hideContainer() {
this.container.style.visibility = 'hidden';
- this.container.blur();
+ }
+
+ isContainerHidden() {
+ return (this.container.style.visibility === 'hidden');
}
isVisible() {
- return this.injected && this.container.style.visibility !== 'hidden';
+ return this.isInjected && this.container.style.visibility !== 'hidden';
}
setVisible(visible) {
@@ -194,19 +238,27 @@ class Popup {
}
}
- containsPoint(point) {
- if (!this.isVisible()) {
- return false;
+ focusParent() {
+ if (this.parent && this.parent.container) {
+ // Chrome doesn't like focusing iframe without contentWindow.
+ this.parent.container.contentWindow.focus();
+ } else {
+ // Firefox doesn't like focusing window without first blurring the iframe.
+ // this.container.contentWindow.blur() doesn't work on Firefox for some reason.
+ this.container.blur();
+ // This is needed for Chrome.
+ window.focus();
}
+ }
- const rect = this.container.getBoundingClientRect();
- const contained =
- point.x >= rect.left &&
- point.y >= rect.top &&
- point.x < rect.right &&
- point.y < rect.bottom;
-
- return contained;
+ async containsPoint({x, y}) {
+ for (let popup = this; popup !== null && popup.isVisible(); popup = popup.child) {
+ const rect = popup.container.getBoundingClientRect();
+ if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
+ return true;
+ }
+ }
+ return false;
}
async termsShow(elementRect, writingMode, definitions, options, context) {
@@ -220,7 +272,7 @@ class Popup {
}
clearAutoPlayTimer() {
- if (this.injected) {
+ if (this.isInjected) {
this.invokeApi('clearAutoPlayTimer');
}
}
diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js
index e724488d..385b5001 100644
--- a/ext/fg/js/source.js
+++ b/ext/fg/js/source.js
@@ -232,6 +232,50 @@ class TextSourceRange {
const writingMode = style.writingMode;
return typeof writingMode === 'string' ? writingMode : 'horizontal-tb';
}
+
+ static getNodesInRange(range) {
+ const end = range.endContainer;
+ const nodes = [];
+ for (let node = range.startContainer; node !== null; node = TextSourceRange.getNextNode(node)) {
+ nodes.push(node);
+ if (node === end) { break; }
+ }
+ return nodes;
+ }
+
+ static getNextNode(node) {
+ let next = node.firstChild;
+ if (next === null) {
+ while (true) {
+ next = node.nextSibling;
+ if (next !== null) { break; }
+
+ next = node.parentNode;
+ if (node === null) { break; }
+
+ node = next;
+ }
+ }
+ return next;
+ }
+
+ static anyNodeMatchesSelector(nodeList, selector) {
+ for (const node of nodeList) {
+ if (TextSourceRange.nodeMatchesSelector(node, selector)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static nodeMatchesSelector(node, selector) {
+ for (; node !== null; node = node.parentNode) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ return node.matches(selector);
+ }
+ }
+ return false;
+ }
}
diff --git a/ext/fg/js/util.js b/ext/fg/js/util.js
index 7518beb5..dc99274e 100644
--- a/ext/fg/js/util.js
+++ b/ext/fg/js/util.js
@@ -24,9 +24,10 @@ function utilAsync(func) {
}
function utilInvoke(action, params={}) {
+ const data = {action, params};
return new Promise((resolve, reject) => {
try {
- chrome.runtime.sendMessage({action, params}, (response) => {
+ chrome.runtime.sendMessage(data, (response) => {
utilCheckLastError(chrome.runtime.lastError);
if (response !== null && typeof response === 'object') {
if (response.error) {
@@ -35,7 +36,8 @@ function utilInvoke(action, params={}) {
resolve(response.result);
}
} else {
- reject(`Unexpected response of type ${typeof response}`);
+ const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`;
+ reject(`${message} (${JSON.stringify(data)})`);
}
});
} catch (e) {
diff --git a/ext/manifest.json b/ext/manifest.json
index 62eed6ec..06d13dd1 100644
--- a/ext/manifest.json
+++ b/ext/manifest.json
@@ -21,9 +21,11 @@
"mixed/js/extension.js",
"fg/js/api.js",
"fg/js/document.js",
+ "fg/js/frontend-api-receiver.js",
"fg/js/popup.js",
"fg/js/source.js",
"fg/js/util.js",
+ "fg/js/popup-proxy-host.js",
"fg/js/frontend.js"
],
"css": ["fg/css/client.css"],