diff options
Diffstat (limited to 'ext/fg/js')
| -rw-r--r-- | ext/fg/js/api.js | 4 | ||||
| -rw-r--r-- | ext/fg/js/float.js | 4 | ||||
| -rw-r--r-- | ext/fg/js/frontend-api-receiver.js | 62 | ||||
| -rw-r--r-- | ext/fg/js/frontend-api-sender.js | 127 | ||||
| -rw-r--r-- | ext/fg/js/frontend.js | 64 | ||||
| -rw-r--r-- | ext/fg/js/popup-nested.js | 51 | ||||
| -rw-r--r-- | ext/fg/js/popup-proxy-host.js | 134 | ||||
| -rw-r--r-- | ext/fg/js/popup-proxy.js | 113 | ||||
| -rw-r--r-- | ext/fg/js/popup.js | 112 | ||||
| -rw-r--r-- | ext/fg/js/source.js | 44 | ||||
| -rw-r--r-- | ext/fg/js/util.js | 6 | 
11 files changed, 678 insertions, 43 deletions
| 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) { |