diff options
| author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2019-08-17 18:50:48 -0400 | 
|---|---|---|
| committer | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2019-09-02 19:31:42 -0400 | 
| commit | 5c4614f585648c2b835efc1d369e78918bc4f5ff (patch) | |
| tree | 50f7aee35187192b7104752d953c18d52bcfba6c | |
| parent | 4ac55da7dd5354e6c3495f04583352d0d863b7b6 (diff) | |
Add support for showing recursive popups
| -rw-r--r-- | ext/bg/background.html | 1 | ||||
| -rw-r--r-- | ext/bg/js/backend-api-forwarder.js | 45 | ||||
| -rw-r--r-- | ext/bg/js/backend.js | 2 | ||||
| -rw-r--r-- | ext/fg/float.html | 6 | ||||
| -rw-r--r-- | ext/fg/js/frontend-api-receiver.js | 62 | ||||
| -rw-r--r-- | ext/fg/js/frontend-api-sender.js | 125 | ||||
| -rw-r--r-- | ext/fg/js/frontend.js | 34 | ||||
| -rw-r--r-- | ext/fg/js/popup-proxy-host.js | 118 | ||||
| -rw-r--r-- | ext/fg/js/popup-proxy.js | 116 | ||||
| -rw-r--r-- | ext/fg/js/popup.js | 45 | ||||
| -rw-r--r-- | ext/fg/js/util.js | 6 | ||||
| -rw-r--r-- | ext/manifest.json | 2 | 
12 files changed, 550 insertions, 12 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/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..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 @@          <script src="/mixed/js/display.js"></script>          <script src="/fg/js/float.js"></script> + +        <!-- TODO : Make these conditional based on options --> +        <script src="/fg/js/frontend-api-sender.js"></script> +        <script src="/fg/js/popup.js"></script> +        <script src="/fg/js/popup-proxy.js"></script> +        <script src="/fg/js/frontend.js"></script>      </body>  </html> 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 <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}) { +        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 <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.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 <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 = 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 <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) { +        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" |