diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index cb759b72..da1f1ce0 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -108,7 +108,8 @@ "enableClipboardMonitor", "showPitchAccentDownstepNotation", "showPitchAccentPositionNotation", - "showPitchAccentGraph" + "showPitchAccentGraph", + "showIframePopupsInRootFrame" ], "properties": { "enable": { @@ -242,6 +243,10 @@ "showPitchAccentGraph": { "type": "boolean", "default": false + }, + "showIframePopupsInRootFrame": { + "type": "boolean", + "default": false } } }, diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index b36fe812..5c68c403 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -127,7 +127,8 @@ function profileOptionsCreateDefaults() { enableClipboardMonitor: false, showPitchAccentDownstepNotation: true, showPitchAccentPositionNotation: true, - showPitchAccentGraph: false + showPitchAccentGraph: false, + showIframePopupsInRootFrame: false }, audio: { diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index 2d2aa8d4..f130a6fa 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -35,6 +35,7 @@ async function searchFrontendSetup() { const scriptSrcs = [ '/mixed/js/text-scanner.js', '/fg/js/frontend-api-receiver.js', + '/fg/js/frame-offset-forwarder.js', '/fg/js/popup.js', '/fg/js/popup-proxy-host.js', '/fg/js/frontend.js', diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index 7caeaea0..1653ee35 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -87,6 +87,7 @@ async function formRead(options) { options.general.showPitchAccentDownstepNotation = $('#show-pitch-accent-downstep-notation').prop('checked'); options.general.showPitchAccentPositionNotation = $('#show-pitch-accent-position-notation').prop('checked'); options.general.showPitchAccentGraph = $('#show-pitch-accent-graph').prop('checked'); + options.general.showIframePopupsInRootFrame = $('#show-iframe-popups-in-root-frame').prop('checked'); options.general.popupTheme = $('#popup-theme').val(); options.general.popupOuterTheme = $('#popup-outer-theme').val(); options.general.customPopupCss = $('#custom-popup-css').val(); @@ -167,6 +168,7 @@ async function formWrite(options) { $('#show-pitch-accent-downstep-notation').prop('checked', options.general.showPitchAccentDownstepNotation); $('#show-pitch-accent-position-notation').prop('checked', options.general.showPitchAccentPositionNotation); $('#show-pitch-accent-graph').prop('checked', options.general.showPitchAccentGraph); + $('#show-iframe-popups-in-root-frame').prop('checked', options.general.showIframePopupsInRootFrame); $('#popup-theme').val(options.general.popupTheme); $('#popup-outer-theme').val(options.general.popupOuterTheme); $('#custom-popup-css').val(options.general.customPopupCss); diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 0b2e4f9c..237162c7 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -174,6 +174,10 @@ +
+ +
+
diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js new file mode 100644 index 00000000..7b417b6e --- /dev/null +++ b/ext/fg/js/frame-offset-forwarder.js @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2020 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 . + */ + +/* global + * apiForward + */ + +class FrameOffsetForwarder { + constructor() { + this._started = false; + + this._forwardFrameOffset = ( + window !== window.parent ? + this._forwardFrameOffsetParent.bind(this) : + this._forwardFrameOffsetOrigin.bind(this) + ); + + this._windowMessageHandlers = new Map([ + ['getFrameOffset', ({offset, uniqueId}, e) => this._onGetFrameOffset(offset, uniqueId, e)] + ]); + } + + start() { + if (this._started) { return; } + window.addEventListener('message', this.onMessage.bind(this), false); + this._started = true; + } + + async getOffset() { + const uniqueId = yomichan.generateId(16); + + const frameOffsetPromise = yomichan.getTemporaryListenerResult( + chrome.runtime.onMessage, + ({action, params}, {resolve}) => { + if (action === 'frameOffset' && isObject(params) && params.uniqueId === uniqueId) { + resolve(params); + } + }, + 5000 + ); + + window.parent.postMessage({ + action: 'getFrameOffset', + params: { + uniqueId, + offset: [0, 0] + } + }, '*'); + + const {offset} = await frameOffsetPromise; + return offset; + } + + onMessage(e) { + const {action, params} = e.data; + const handler = this._windowMessageHandlers.get(action); + if (typeof handler !== 'function') { return; } + handler(params, e); + } + + _onGetFrameOffset(offset, uniqueId, e) { + let sourceFrame = null; + for (const frame of document.querySelectorAll('frame, iframe:not(.yomichan-float)')) { + if (frame.contentWindow !== e.source) { continue; } + sourceFrame = frame; + break; + } + if (sourceFrame === null) { + this._forwardFrameOffsetOrigin(null, uniqueId); + return; + } + + const [forwardedX, forwardedY] = offset; + const {x, y} = sourceFrame.getBoundingClientRect(); + offset = [forwardedX + x, forwardedY + y]; + + this._forwardFrameOffset(offset, uniqueId); + } + + _forwardFrameOffsetParent(offset, uniqueId) { + window.parent.postMessage({action: 'getFrameOffset', params: {offset, uniqueId}}, '*'); + } + + _forwardFrameOffsetOrigin(offset, uniqueId) { + apiForward('frameOffset', {offset, uniqueId}); + } +} diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/frontend-initialize.js index 3a191247..4a1409db 100644 --- a/ext/fg/js/frontend-initialize.js +++ b/ext/fg/js/frontend-initialize.js @@ -17,9 +17,12 @@ */ /* global + * FrameOffsetForwarder * Frontend * PopupProxy * PopupProxyHost + * apiForward + * apiOptionsGet */ async function main() { @@ -28,10 +31,35 @@ async function main() { const data = window.frontendInitializationData || {}; const {id, depth=0, parentFrameId, url, proxy=false} = data; + const optionsContext = {depth, url}; + const options = await apiOptionsGet(optionsContext); + let popup; - if (proxy) { + if (!proxy && (window !== window.parent) && options.general.showIframePopupsInRootFrame) { + const rootPopupInformationPromise = yomichan.getTemporaryListenerResult( + chrome.runtime.onMessage, + ({action, params}, {resolve}) => { + if (action === 'rootPopupInformation') { + resolve(params); + } + } + ); + apiForward('rootPopupRequestInformationBroadcast'); + const {popupId, frameId} = await rootPopupInformationPromise; + + const frameOffsetForwarder = new FrameOffsetForwarder(); + frameOffsetForwarder.start(); + const getFrameOffset = frameOffsetForwarder.getOffset.bind(frameOffsetForwarder); + + popup = new PopupProxy(popupId, 0, null, frameId, url, getFrameOffset); + await popup.prepare(); + } else if (proxy) { popup = new PopupProxy(null, depth + 1, id, parentFrameId, url); + await popup.prepare(); } else { + const frameOffsetForwarder = new FrameOffsetForwarder(); + frameOffsetForwarder.start(); + const popupHost = new PopupProxyHost(); await popupHost.prepare(); diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index d6c5eac6..4e9d474c 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -18,6 +18,7 @@ /* global * TextScanner + * apiForward * apiGetZoom * apiKanjiFind * apiOptionsGet @@ -52,7 +53,8 @@ class Frontend extends TextScanner { ]); this._runtimeMessageHandlers = new Map([ - ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }] + ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }], + ['rootPopupRequestInformationBroadcast', () => { this._broadcastRootPopupInformation(); }] ]); } @@ -76,6 +78,7 @@ class Frontend extends TextScanner { chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); this._updateContentScale(); + this._broadcastRootPopupInformation(); } catch (e) { this.onError(e); } @@ -255,6 +258,12 @@ class Frontend extends TextScanner { this._updatePopupPosition(); } + _broadcastRootPopupInformation() { + if (!this.popup.isProxy() && this.popup.depth === 0) { + apiForward('rootPopupInformation', {popupId: this.popup.id, frameId: this.popup.frameId}); + } + } + async _updatePopupPosition() { const textSource = this.getCurrentTextSource(); if (textSource !== null && await this.popup.isVisible()) { diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js index 6f1c13c6..4b136e41 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-proxy-host.js @@ -26,17 +26,17 @@ class PopupProxyHost { constructor() { this._popups = new Map(); this._apiReceiver = null; - this._frameIdPromise = null; + this._frameId = null; } // Public functions async prepare() { - this._frameIdPromise = apiFrameInformationGet(); - const {frameId} = await this._frameIdPromise; + const {frameId} = await apiFrameInformationGet(); if (typeof frameId !== 'number') { return; } + this._frameId = frameId; - this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, new Map([ + this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${this._frameId}`, new Map([ ['getOrCreatePopup', this._onApiGetOrCreatePopup.bind(this)], ['setOptions', this._onApiSetOptions.bind(this)], ['hide', this._onApiHide.bind(this)], @@ -87,7 +87,7 @@ class PopupProxyHost { } else if (depth === null) { depth = 0; } - const popup = new Popup(id, depth, this._frameIdPromise); + const popup = new Popup(id, depth, this._frameId); if (parent !== null) { popup.setParent(parent); } @@ -95,7 +95,7 @@ class PopupProxyHost { return popup; } - // Message handlers + // API message handlers async _onApiGetOrCreatePopup({id, parentId}) { const popup = this.getOrCreatePopup(id, parentId); diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index 997b1317..966198a9 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -21,14 +21,18 @@ */ class PopupProxy { - constructor(id, depth, parentId, parentFrameId, url) { + constructor(id, depth, parentId, parentFrameId, url, getFrameOffset=null) { this._parentId = parentId; this._parentFrameId = parentFrameId; this._id = id; - this._idPromise = null; this._depth = depth; this._url = url; this._apiSender = new FrontendApiSender(); + this._getFrameOffset = getFrameOffset; + + this._frameOffset = null; + this._frameOffsetPromise = null; + this._frameOffsetUpdatedAt = null; } // Public properties @@ -51,79 +55,63 @@ class PopupProxy { // Public functions + async prepare() { + const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId}); + this._id = id; + } + isProxy() { return true; } async setOptions(options) { - const id = await this._getPopupId(); - return await this._invokeHostApi('setOptions', {id, options}); + return await this._invokeHostApi('setOptions', {id: this._id, options}); } hide(changeFocus) { - if (this._id === null) { - return; - } this._invokeHostApi('hide', {id: this._id, changeFocus}); } async isVisible() { - const id = await this._getPopupId(); - return await this._invokeHostApi('isVisible', {id}); + return await this._invokeHostApi('isVisible', {id: this._id}); } setVisibleOverride(visible) { - if (this._id === null) { - return; - } this._invokeHostApi('setVisibleOverride', {id: this._id, visible}); } async containsPoint(x, y) { - if (this._id === null) { - return false; + if (this._getFrameOffset !== null) { + await this._updateFrameOffset(); + [x, y] = this._applyFrameOffset(x, y); } return await this._invokeHostApi('containsPoint', {id: this._id, x, y}); } async showContent(elementRect, writingMode, type=null, details=null) { - const id = await this._getPopupId(); - elementRect = PopupProxy._convertDOMRectToJson(elementRect); - return await this._invokeHostApi('showContent', {id, elementRect, writingMode, type, details}); + let {x, y, width, height} = elementRect; + if (this._getFrameOffset !== null) { + await this._updateFrameOffset(); + [x, y] = this._applyFrameOffset(x, y); + } + elementRect = {x, y, width, height}; + return await this._invokeHostApi('showContent', {id: this._id, elementRect, writingMode, type, details}); } async setCustomCss(css) { - const id = await this._getPopupId(); - return await this._invokeHostApi('setCustomCss', {id, css}); + return await this._invokeHostApi('setCustomCss', {id: this._id, css}); } clearAutoPlayTimer() { - if (this._id === null) { - return; - } this._invokeHostApi('clearAutoPlayTimer', {id: this._id}); } async setContentScale(scale) { - const id = await this._getPopupId(); - this._invokeHostApi('setContentScale', {id, scale}); + this._invokeHostApi('setContentScale', {id: this._id, scale}); } // Private - _getPopupId() { - if (this._idPromise === null) { - this._idPromise = this._getPopupIdAsync(); - } - return this._idPromise; - } - - async _getPopupIdAsync() { - const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId}); - this._id = id; - return id; - } - _invokeHostApi(action, params={}) { if (typeof this._parentFrameId !== 'number') { return Promise.reject(new Error('Invalid frame')); @@ -131,12 +119,42 @@ class PopupProxy { return this._apiSender.invoke(action, params, `popup-proxy-host#${this._parentFrameId}`); } - static _convertDOMRectToJson(domRect) { - return { - x: domRect.x, - y: domRect.y, - width: domRect.width, - height: domRect.height - }; + async _updateFrameOffset() { + const now = Date.now(); + const firstRun = this._frameOffsetUpdatedAt === null; + const expired = firstRun || this._frameOffsetUpdatedAt < now - PopupProxy._frameOffsetExpireTimeout; + if (this._frameOffsetPromise === null && !expired) { return; } + + if (this._frameOffsetPromise !== null) { + if (firstRun) { + await this._frameOffsetPromise; + } + return; + } + + const promise = this._updateFrameOffsetInner(now); + if (firstRun) { + await promise; + } + } + + async _updateFrameOffsetInner(now) { + this._frameOffsetPromise = this._getFrameOffset(); + try { + const offset = await this._frameOffsetPromise; + this._frameOffset = offset !== null ? offset : [0, 0]; + this._frameOffsetUpdatedAt = now; + } catch (e) { + logError(e); + } finally { + this._frameOffsetPromise = null; + } + } + + _applyFrameOffset(x, y) { + const [offsetX, offsetY] = this._frameOffset; + return [x + offsetX, y + offsetY]; } } + +PopupProxy._frameOffsetExpireTimeout = 1000; diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index e6e93a76..60dc16dd 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -22,11 +22,10 @@ */ class Popup { - constructor(id, depth, frameIdPromise) { + constructor(id, depth, frameId) { this._id = id; this._depth = depth; - this._frameIdPromise = frameIdPromise; - this._frameId = null; + this._frameId = frameId; this._parent = null; this._child = null; this._childrenSupported = true; @@ -69,6 +68,10 @@ class Popup { return this._depth; } + get frameId() { + return this._frameId; + } + get url() { return window.location.href; } @@ -193,40 +196,42 @@ class Popup { } async _createInjectPromise() { - try { - const {frameId} = await this._frameIdPromise; - if (typeof frameId === 'number') { - this._frameId = frameId; - } - } catch (e) { - // NOP - } - if (this._messageToken === null) { this._messageToken = await apiGetMessageToken(); } - return new Promise((resolve) => { - const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null); - this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html')); - this._container.addEventListener('load', () => { - this._listenForDisplayPrepareCompleted(resolve); + const popupPreparedPromise = yomichan.getTemporaryListenerResult( + chrome.runtime.onMessage, + ({action, params}, {resolve}) => { + if ( + action === 'popupPrepareCompleted' && + isObject(params) && + params.targetPopupId === this._id + ) { + resolve(); + } + } + ); - this._invokeApi('prepare', { - popupInfo: { - id: this._id, - depth: this._depth, - parentFrameId - }, - url: this.url, - childrenSupported: this._childrenSupported, - scale: this._contentScale - }); + const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null); + this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html')); + this._container.addEventListener('load', () => { + this._invokeApi('prepare', { + popupInfo: { + id: this._id, + depth: this._depth, + parentFrameId + }, + url: this.url, + childrenSupported: this._childrenSupported, + scale: this._contentScale }); - this._observeFullscreen(true); - this._onFullscreenChanged(); - this._injectStyles(); }); + this._observeFullscreen(true); + this._onFullscreenChanged(); + this._injectStyles(); + + return popupPreparedPromise; } async _injectStyles() { @@ -361,22 +366,6 @@ class Popup { contentWindow.postMessage({action, params, token}, this._targetOrigin); } - _listenForDisplayPrepareCompleted(resolve) { - const runtimeMessageCallback = ({action, params}, sender, callback) => { - if ( - action === 'popupPrepareCompleted' && - isObject(params) && - params.targetPopupId === this._id - ) { - chrome.runtime.onMessage.removeListener(runtimeMessageCallback); - callback(); - resolve(); - return false; - } - }; - chrome.runtime.onMessage.addListener(runtimeMessageCallback); - } - static _getFullscreenElement() { return ( document.fullscreenElement || diff --git a/ext/manifest.json b/ext/manifest.json index 3cea8647..98965389 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -23,9 +23,12 @@ "mixed/js/api.js", "mixed/js/text-scanner.js", "fg/js/document.js", + "fg/js/frontend-api-sender.js", "fg/js/frontend-api-receiver.js", "fg/js/popup.js", "fg/js/source.js", + "fg/js/frame-offset-forwarder.js", + "fg/js/popup-proxy.js", "fg/js/popup-proxy-host.js", "fg/js/frontend.js", "fg/js/frontend-initialize.js" diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index fd762e97..db7fc69b 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -278,11 +278,16 @@ const yomichan = (() => { constructor() { super(); - this._isBackendPreparedResolve = null; - this._isBackendPreparedPromise = new Promise((resolve) => (this._isBackendPreparedResolve = resolve)); + this._isBackendPreparedPromise = this.getTemporaryListenerResult( + chrome.runtime.onMessage, + ({action}, {resolve}) => { + if (action === 'backendPrepared') { + resolve(); + } + } + ); this._messageHandlers = new Map([ - ['backendPrepared', this._onBackendPrepared.bind(this)], ['getUrl', this._onMessageGetUrl.bind(this)], ['optionsUpdated', this._onMessageOptionsUpdated.bind(this)], ['zoomChanged', this._onMessageZoomChanged.bind(this)] @@ -312,6 +317,42 @@ const yomichan = (() => { this.trigger('orphaned', {error}); } + getTemporaryListenerResult(eventHandler, userCallback, timeout=null) { + if (!( + typeof eventHandler.addListener === 'function' && + typeof eventHandler.removeListener === 'function' + )) { + throw new Error('Event handler type not supported'); + } + + return new Promise((resolve, reject) => { + const runtimeMessageCallback = ({action, params}, sender, sendResponse) => { + let timeoutId = null; + if (timeout !== null) { + timeoutId = window.setTimeout(() => { + timeoutId = null; + eventHandler.removeListener(runtimeMessageCallback); + reject(new Error(`Listener timed out in ${timeout} ms`)); + }, timeout); + } + + const cleanupResolve = (value) => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + eventHandler.removeListener(runtimeMessageCallback); + sendResponse(); + resolve(value); + }; + + userCallback({action, params}, {resolve: cleanupResolve, sender}); + }; + + eventHandler.addListener(runtimeMessageCallback); + }); + } + // Private _onMessage({action, params}, sender, callback) { @@ -323,10 +364,6 @@ const yomichan = (() => { return false; } - _onBackendPrepared() { - this._isBackendPreparedResolve(); - } - _onMessageGetUrl() { return {url: window.location.href}; } diff --git a/test/test-database.js b/test/test-database.js index c4bfb793..bab15aa4 100644 --- a/test/test-database.js +++ b/test/test-database.js @@ -27,7 +27,8 @@ require('fake-indexeddb/auto'); const chrome = { runtime: { onMessage: { - addListener() { /* NOP */ } + addListener() { /* NOP */ }, + removeListener() { /* NOP */ } }, getURL(path2) { return url.pathToFileURL(path.join(__dirname, '..', 'ext', path2.replace(/^\//, '')));