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 @@ + 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 + * Author: Alex Yatskov + * + * 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 . + */ + + +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..8f99c13a 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() { diff --git a/ext/fg/float.html b/ext/fg/float.html index 0133e653..bd08296a 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -43,5 +43,11 @@ + + + + + + diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js new file mode 100644 index 00000000..f5d29f67 --- /dev/null +++ b/ext/fg/js/frontend-api-receiver.js @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2019 Alex Yatskov + * Author: Alex Yatskov + * + * 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 . + */ + + +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}) { + if ( + target !== this.source || + !this.handlers.hasOwnProperty(action) + ) { + return; + } + + this.sendAck(port, id); + + const handler = this.handlers[action]; + handler(params).then( + result => { + this.sendResult(port, id, {result}); + }, + e => { + const error = typeof e.toString === 'function' ? e.toString() : e; + this.sendResult(port, id, {error}); + }); + } + + sendAck(port, id) { + port.postMessage({type: 'ack', id}); + } + + sendResult(port, id, data) { + port.postMessage({type: 'result', id, 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..e2becb90 --- /dev/null +++ b/ext/fg/js/frontend-api-sender.js @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2019 Alex Yatskov + * Author: Alex Yatskov + * + * 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 . + */ + + +class FrontendApiSender { + constructor() { + 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}); + }); + } + + onMessage({type, id, data}) { + 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`); + 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..d378dd61 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -18,8 +18,8 @@ class Frontend { - constructor() { - this.popup = new Popup(); + constructor(popup) { + this.popup = popup; this.popupTimer = null; this.mouseDownLeft = false; this.mouseDownMiddle = false; @@ -36,6 +36,25 @@ class Frontend { this.scrollPrevent = false; } + static create() { + const floatUrl = chrome.extension.getURL('/fg/float.html'); + const currentUrl = location.href.replace(/[\?#][\w\W]*$/, ""); + const isNested = (currentUrl === floatUrl); + + let id = null; + if (isNested) { + const match = /[&?]id=([^&]*?)(?:&|$)/.exec(location.href); + if (match !== null) { + id = match[1]; + } + } + + const popup = isNested ? new PopupProxy(id) : PopupProxyHost.instance.createPopup(); + const frontend = new Frontend(popup); + frontend.prepare(); + return frontend; + } + async prepare() { try { this.options = await apiOptionsGet(); @@ -259,9 +278,8 @@ class Frontend { const handler = handlers[action]; if (handler) { handler(params); + callback(); } - - callback(); } onError(error) { @@ -281,7 +299,10 @@ class Frontend { } async searchAt(point, type) { - if (this.pendingLookup || this.popup.containsPoint(point)) { + if ( + this.pendingLookup || + (this.popup.containsPointIsAsync() ? await this.popup.containsPointAsync(point) : this.popup.containsPoint(point)) + ) { return; } @@ -482,5 +503,4 @@ class Frontend { } } -window.yomichan_frontend = new Frontend(); -window.yomichan_frontend.prepare(); +window.yomichan_frontend = Frontend.create(); diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js new file mode 100644 index 00000000..189481bc --- /dev/null +++ b/ext/fg/js/popup-proxy-host.js @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2019 Alex Yatskov + * Author: Alex Yatskov + * + * 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 . + */ + + +class PopupProxyHost { + constructor() { + this.popups = {}; + this.nextId = 0; + this.apiReceiver = new FrontendApiReceiver('popup-proxy-host', { + 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 id = `${this.nextId}`; + ++this.nextId; + const popup = new Popup(id); + if (parent !== null) { + popup.parent = parent; + parent.children.push(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 popup.containsPointIsAsync() ? await popup.containsPointAsync(point) : 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 = new PopupProxyHost(); diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js new file mode 100644 index 00000000..3a15be7d --- /dev/null +++ b/ext/fg/js/popup-proxy.js @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2019 Alex Yatskov + * Author: Alex Yatskov + * + * 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 . + */ + + +class PopupProxy { + constructor(parentId) { + this.parentId = parentId; + this.id = null; + this.idPromise = null; + this.parent = null; + this.children = []; + + 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}); + } + + containsPoint() { + throw 'Non-async function not supported'; + } + + async containsPointAsync(point) { + if (this.id === null) { + return false; + } + return await this.invokeHostApi('containsPoint', {id: this.id, point}); + } + + containsPointIsAsync() { + return true; + } + + 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={}) { + return this.apiSender.invoke(action, params, 'popup-proxy-host'); + } + + 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..f6b4f6d9 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -18,12 +18,15 @@ class Popup { - constructor() { + constructor(id) { + this.id = id; + this.parent = null; + this.children = []; this.container = document.createElement('iframe'); this.container.id = '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.extension.getURL(`/fg/float.html?id=${id}`)); this.container.style.width = '0px'; this.container.style.height = '0px'; this.injected = null; @@ -77,6 +80,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,8 +183,34 @@ class Popup { } hide() { - this.container.style.visibility = 'hidden'; + this.hideContainer(); this.container.blur(); + this.hideChildren(); + } + + hideChildren() { + if (this.children.length === 0) { + return; + } + + const targets = this.children.slice(0); + while (targets.length > 0) { + const target = targets.shift(); + if (target.isContainerHidden()) { continue; } + + target.hideContainer(); + for (const child of target.children) { + targets.push(child); + } + } + } + + hideContainer() { + this.container.style.visibility = 'hidden'; + } + + isContainerHidden() { + return (this.container.style.visibility === 'hidden'); } isVisible() { @@ -209,6 +240,14 @@ class Popup { return contained; } + async containsPointAsync(point) { + return containsPoint(point); + } + + containsPointIsAsync() { + return false; + } + async termsShow(elementRect, writingMode, definitions, options, context) { await this.show(elementRect, writingMode, options); this.invokeApi('termsShow', {definitions, options, context}); 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..eed6e40a 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -21,7 +21,9 @@ "mixed/js/extension.js", "fg/js/api.js", "fg/js/document.js", + "fg/js/frontend-api-receiver.js", "fg/js/popup.js", + "fg/js/popup-proxy-host.js", "fg/js/source.js", "fg/js/util.js", "fg/js/frontend.js"