diff options
Diffstat (limited to 'ext/js/app')
-rw-r--r-- | ext/js/app/content-script-main.js | 59 | ||||
-rw-r--r-- | ext/js/app/frontend.js | 691 | ||||
-rw-r--r-- | ext/js/app/popup-factory.js | 319 | ||||
-rw-r--r-- | ext/js/app/popup-proxy.js | 218 | ||||
-rw-r--r-- | ext/js/app/popup-window.js | 169 | ||||
-rw-r--r-- | ext/js/app/popup.js | 687 |
6 files changed, 2143 insertions, 0 deletions
diff --git a/ext/js/app/content-script-main.js b/ext/js/app/content-script-main.js new file mode 100644 index 00000000..5dee4c56 --- /dev/null +++ b/ext/js/app/content-script-main.js @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * Frontend + * HotkeyHandler + * PopupFactory + * api + */ + +(async () => { + try { + api.forwardLogsToBackend(); + await yomichan.backendReady(); + + const {tabId, frameId} = await api.frameInformationGet(); + if (typeof frameId !== 'number') { + throw new Error('Failed to get frameId'); + } + + const hotkeyHandler = new HotkeyHandler(); + hotkeyHandler.prepare(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const frontend = new Frontend({ + tabId, + frameId, + popupFactory, + depth: 0, + parentPopupId: null, + parentFrameId: null, + useProxyPopup: false, + pageType: 'web', + allowRootFramePopupProxy: true, + hotkeyHandler + }); + await frontend.prepare(); + + yomichan.ready(); + } catch (e) { + yomichan.logError(e); + } +})(); diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js new file mode 100644 index 00000000..a62b06bf --- /dev/null +++ b/ext/js/app/frontend.js @@ -0,0 +1,691 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * DocumentUtil + * TextScanner + * TextSourceElement + * TextSourceRange + * api + */ + +class Frontend { + constructor({ + pageType, + popupFactory, + depth, + tabId, + frameId, + parentPopupId, + parentFrameId, + useProxyPopup, + canUseWindowPopup=true, + allowRootFramePopupProxy, + childrenSupported=true, + hotkeyHandler + }) { + this._pageType = pageType; + this._popupFactory = popupFactory; + this._depth = depth; + this._tabId = tabId; + this._frameId = frameId; + this._parentPopupId = parentPopupId; + this._parentFrameId = parentFrameId; + this._useProxyPopup = useProxyPopup; + this._canUseWindowPopup = canUseWindowPopup; + this._allowRootFramePopupProxy = allowRootFramePopupProxy; + this._childrenSupported = childrenSupported; + this._hotkeyHandler = hotkeyHandler; + this._popup = null; + this._disabledOverride = false; + this._options = null; + this._pageZoomFactor = 1.0; + this._contentScale = 1.0; + this._lastShowPromise = Promise.resolve(); + this._documentUtil = new DocumentUtil(); + this._textScanner = new TextScanner({ + node: window, + ignoreElements: this._ignoreElements.bind(this), + ignorePoint: this._ignorePoint.bind(this), + getSearchContext: this._getSearchContext.bind(this), + documentUtil: this._documentUtil, + searchTerms: true, + searchKanji: true + }); + this._popupCache = new Map(); + this._popupEventListeners = new EventListenerCollection(); + this._updatePopupToken = null; + this._clearSelectionTimer = null; + this._isPointerOverPopup = false; + this._optionsContextOverride = null; + + this._runtimeMessageHandlers = new Map([ + ['requestFrontendReadyBroadcast', {async: false, handler: this._onMessageRequestFrontendReadyBroadcast.bind(this)}], + ['setAllVisibleOverride', {async: true, handler: this._onApiSetAllVisibleOverride.bind(this)}], + ['clearAllVisibleOverride', {async: true, handler: this._onApiClearAllVisibleOverride.bind(this)}] + ]); + + this._hotkeyHandler.registerActions([ + ['scanSelectedText', this._onActionScanSelectedText.bind(this)] + ]); + } + + get canClearSelection() { + return this._textScanner.canClearSelection; + } + + set canClearSelection(value) { + this._textScanner.canClearSelection = value; + } + + get popup() { + return this._popup; + } + + async prepare() { + await this.updateOptions(); + try { + const {zoomFactor} = await api.getZoom(); + this._pageZoomFactor = zoomFactor; + } catch (e) { + // Ignore exceptions which may occur due to being on an unsupported page (e.g. about:blank) + } + + this._textScanner.prepare(); + + window.addEventListener('resize', this._onResize.bind(this), false); + DocumentUtil.addFullscreenChangeEventListener(this._updatePopup.bind(this)); + + const visualViewport = window.visualViewport; + if (visualViewport !== null && typeof visualViewport === 'object') { + visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this)); + visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this)); + } + + yomichan.on('optionsUpdated', this.updateOptions.bind(this)); + yomichan.on('zoomChanged', this._onZoomChanged.bind(this)); + yomichan.on('closePopups', this._onClosePopups.bind(this)); + chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this)); + + this._textScanner.on('clearSelection', this._onClearSelection.bind(this)); + this._textScanner.on('searched', this._onSearched.bind(this)); + + api.crossFrame.registerHandlers([ + ['closePopup', {async: false, handler: this._onApiClosePopup.bind(this)}], + ['copySelection', {async: false, handler: this._onApiCopySelection.bind(this)}], + ['getSelectionText', {async: false, handler: this._onApiGetSelectionText.bind(this)}], + ['getPopupInfo', {async: false, handler: this._onApiGetPopupInfo.bind(this)}], + ['getPageInfo', {async: false, handler: this._onApiGetPageInfo.bind(this)}], + ['getFrameSize', {async: true, handler: this._onApiGetFrameSize.bind(this)}], + ['setFrameSize', {async: true, handler: this._onApiSetFrameSize.bind(this)}] + ]); + + this._updateContentScale(); + this._signalFrontendReady(); + } + + setDisabledOverride(disabled) { + this._disabledOverride = disabled; + this._updateTextScannerEnabled(); + } + + setOptionsContextOverride(optionsContext) { + this._optionsContextOverride = optionsContext; + } + + async setTextSource(textSource) { + this._textScanner.setCurrentTextSource(null); + await this._textScanner.search(textSource); + } + + async updateOptions() { + try { + await this._updateOptionsInternal(); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { + throw e; + } + } + } + + showContentCompleted() { + return this._lastShowPromise; + } + + // Message handlers + + _onMessageRequestFrontendReadyBroadcast({frameId}) { + this._signalFrontendReady(frameId); + } + + // Action handlers + + _onActionScanSelectedText() { + this._scanSelectedText(); + } + + // API message handlers + + _onApiGetUrl() { + return window.location.href; + } + + _onApiClosePopup() { + this._clearSelection(false); + } + + _onApiCopySelection() { + // This will not work on Firefox if a popup has focus, which is usually the case when this function is called. + document.execCommand('copy'); + } + + _onApiGetSelectionText() { + return document.getSelection().toString(); + } + + _onApiGetPopupInfo() { + return { + popupId: (this._popup !== null ? this._popup.id : null) + }; + } + + _onApiGetPageInfo() { + return { + url: window.location.href, + documentTitle: document.title + }; + } + + async _onApiSetAllVisibleOverride({value, priority, awaitFrame}) { + const result = await this._popupFactory.setAllVisibleOverride(value, priority); + if (awaitFrame) { + await promiseAnimationFrame(100); + } + return result; + } + + async _onApiClearAllVisibleOverride({token}) { + return await this._popupFactory.clearAllVisibleOverride(token); + } + + async _onApiGetFrameSize() { + return await this._popup.getFrameSize(); + } + + async _onApiSetFrameSize({width, height}) { + return await this._popup.setFrameSize(width, height); + } + + // Private + + _onResize() { + this._updatePopupPosition(); + } + + _onRuntimeMessage({action, params}, sender, callback) { + const messageHandler = this._runtimeMessageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return false; } + return yomichan.invokeMessageHandler(messageHandler, params, callback, sender); + } + + _onZoomChanged({newZoomFactor}) { + this._pageZoomFactor = newZoomFactor; + this._updateContentScale(); + } + + _onClosePopups() { + this._clearSelection(true); + } + + _onVisualViewportScroll() { + this._updatePopupPosition(); + } + + _onVisualViewportResize() { + this._updateContentScale(); + } + + _onClearSelection({passive}) { + this._stopClearSelectionDelayed(); + if (this._popup !== null) { + this._popup.hide(!passive); + this._popup.clearAutoPlayTimer(); + this._isPointerOverPopup = false; + } + } + + _onSearched({type, definitions, sentence, inputInfo: {eventType, passive, detail}, textSource, optionsContext, detail: {documentTitle}, error}) { + const scanningOptions = this._options.scanning; + + if (error !== null) { + if (yomichan.isExtensionUnloaded) { + if (textSource !== null && !passive) { + this._showExtensionUnloaded(textSource); + } + } else { + yomichan.logError(error); + } + } if (type !== null) { + this._stopClearSelectionDelayed(); + let focus = (eventType === 'mouseMove'); + if (isObject(detail)) { + const focus2 = detail.focus; + if (typeof focus2 === 'boolean') { focus = focus2; } + } + this._showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext); + } else { + if (scanningOptions.autoHideResults) { + this._clearSelectionDelayed(scanningOptions.hideDelay, false); + } + } + } + + _onPopupFramePointerOver() { + this._isPointerOverPopup = true; + this._stopClearSelectionDelayed(); + } + + _onPopupFramePointerOut() { + this._isPointerOverPopup = false; + } + + _clearSelection(passive) { + this._stopClearSelectionDelayed(); + this._textScanner.clearSelection(passive); + } + + _clearSelectionDelayed(delay, restart, passive) { + if (!this._textScanner.hasSelection()) { return; } + if (delay > 0) { + if (this._clearSelectionTimer !== null && !restart) { return; } // Already running + this._stopClearSelectionDelayed(); + this._clearSelectionTimer = setTimeout(() => { + this._clearSelectionTimer = null; + if (this._isPointerOverPopup) { return; } + this._clearSelection(passive); + }, delay); + } else { + this._clearSelection(passive); + } + } + + _stopClearSelectionDelayed() { + if (this._clearSelectionTimer !== null) { + clearTimeout(this._clearSelectionTimer); + this._clearSelectionTimer = null; + } + } + + async _updateOptionsInternal() { + const optionsContext = await this._getOptionsContext(); + const options = await api.optionsGet(optionsContext); + const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options; + this._options = options; + + this._hotkeyHandler.setHotkeys('web', options.inputs.hotkeys); + + await this._updatePopup(); + + const preventMiddleMouse = this._getPreventMiddleMouseValueForPageType(scanningOptions.preventMiddleMouse); + this._textScanner.setOptions({ + inputs: scanningOptions.inputs, + deepContentScan: scanningOptions.deepDomScan, + selectText: scanningOptions.selectText, + delay: scanningOptions.delay, + touchInputEnabled: scanningOptions.touchInputEnabled, + pointerEventsEnabled: scanningOptions.pointerEventsEnabled, + scanLength: scanningOptions.length, + layoutAwareScan: scanningOptions.layoutAwareScan, + preventMiddleMouse, + sentenceParsingOptions + }); + this._updateTextScannerEnabled(); + + if (this._pageType !== 'web') { + const excludeSelectors = ['.scan-disable', '.scan-disable *']; + if (!scanningOptions.enableOnPopupExpressions) { + excludeSelectors.push('.source-text', '.source-text *'); + } + this._textScanner.excludeSelector = excludeSelectors.join(','); + } + + this._updateContentScale(); + + await this._textScanner.searchLast(); + } + + async _updatePopup() { + const {usePopupWindow, showIframePopupsInRootFrame} = this._options.general; + const isIframe = !this._useProxyPopup && (window !== window.parent); + + const currentPopup = this._popup; + + let popupPromise; + if (usePopupWindow && this._canUseWindowPopup) { + popupPromise = this._popupCache.get('window'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getPopupWindow(); + this._popupCache.set('window', popupPromise); + } + } else if ( + isIframe && + showIframePopupsInRootFrame && + DocumentUtil.getFullscreenElement() === null && + this._allowRootFramePopupProxy + ) { + popupPromise = this._popupCache.get('iframe'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getIframeProxyPopup(); + this._popupCache.set('iframe', popupPromise); + } + } else if (this._useProxyPopup) { + popupPromise = this._popupCache.get('proxy'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getProxyPopup(); + this._popupCache.set('proxy', popupPromise); + } + } else { + popupPromise = this._popupCache.get('default'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getDefaultPopup(); + this._popupCache.set('default', popupPromise); + } + } + + // The token below is used as a unique identifier to ensure that a new _updatePopup call + // hasn't been started during the await. + const token = {}; + this._updatePopupToken = token; + const popup = await popupPromise; + const optionsContext = await this._getOptionsContext(); + if (this._updatePopupToken !== token) { return; } + if (popup !== null) { + await popup.setOptionsContext(optionsContext); + } + if (this._updatePopupToken !== token) { return; } + + if (popup !== currentPopup) { + this._clearSelection(true); + } + + this._popupEventListeners.removeAllEventListeners(); + this._popup = popup; + if (popup !== null) { + this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this)); + this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this)); + } + this._isPointerOverPopup = false; + } + + async _getDefaultPopup() { + const isXmlDocument = (typeof XMLDocument !== 'undefined' && document instanceof XMLDocument); + if (isXmlDocument) { + return null; + } + + return await this._popupFactory.getOrCreatePopup({ + frameId: this._frameId, + depth: this._depth, + childrenSupported: this._childrenSupported + }); + } + + async _getProxyPopup() { + return await this._popupFactory.getOrCreatePopup({ + frameId: this._parentFrameId, + depth: this._depth, + parentPopupId: this._parentPopupId, + childrenSupported: this._childrenSupported + }); + } + + async _getIframeProxyPopup() { + const targetFrameId = 0; // Root frameId + try { + await this._waitForFrontendReady(targetFrameId); + } catch (e) { + // Root frame not available + return await this._getDefaultPopup(); + } + + const {popupId} = await api.crossFrame.invoke(targetFrameId, 'getPopupInfo'); + if (popupId === null) { + return null; + } + + const popup = await this._popupFactory.getOrCreatePopup({ + frameId: targetFrameId, + id: popupId, + childrenSupported: this._childrenSupported + }); + popup.on('offsetNotFound', () => { + this._allowRootFramePopupProxy = false; + this._updatePopup(); + }); + return popup; + } + + async _getPopupWindow() { + return await this._popupFactory.getOrCreatePopup({ + depth: this._depth, + popupWindow: true, + childrenSupported: this._childrenSupported + }); + } + + _ignoreElements() { + if (this._popup !== null) { + const container = this._popup.container; + if (container !== null) { + return [container]; + } + } + return []; + } + + async _ignorePoint(x, y) { + try { + return this._popup !== null && await this._popup.containsPoint(x, y); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { + throw e; + } + return false; + } + } + + _showExtensionUnloaded(textSource) { + if (textSource === null) { + textSource = this._textScanner.getCurrentTextSource(); + if (textSource === null) { return; } + } + this._showPopupContent(textSource, null); + } + + _showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext) { + const query = textSource.text(); + const {url} = optionsContext; + const details = { + focus, + history: false, + params: { + type, + query, + wildcards: 'off' + }, + state: { + focusEntry: 0, + optionsContext, + url, + sentence, + documentTitle + }, + content: { + definitions, + contentOrigin: { + tabId: this._tabId, + frameId: this._frameId + } + } + }; + if (textSource instanceof TextSourceElement && textSource.fullContent !== query) { + details.params.full = textSource.fullContent; + details.params['full-visible'] = 'true'; + } + this._showPopupContent(textSource, optionsContext, details); + } + + _showPopupContent(textSource, optionsContext, details=null) { + this._lastShowPromise = ( + this._popup !== null ? + this._popup.showContent( + { + optionsContext, + elementRect: textSource.getRect(), + writingMode: textSource.getWritingMode() + }, + details + ) : + Promise.resolve() + ); + this._lastShowPromise.catch((error) => { + if (yomichan.isExtensionUnloaded) { return; } + yomichan.logError(error); + }); + return this._lastShowPromise; + } + + _updateTextScannerEnabled() { + const enabled = (this._options !== null && this._options.general.enable && !this._disabledOverride); + this._textScanner.setEnabled(enabled); + } + + _updateContentScale() { + const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general; + let contentScale = popupScalingFactor; + if (popupScaleRelativeToPageZoom) { + contentScale /= this._pageZoomFactor; + } + if (popupScaleRelativeToVisualViewport) { + const visualViewport = window.visualViewport; + const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0); + contentScale /= visualViewportScale; + } + if (contentScale === this._contentScale) { return; } + + this._contentScale = contentScale; + if (this._popup !== null) { + this._popup.setContentScale(this._contentScale); + } + this._updatePopupPosition(); + } + + async _updatePopupPosition() { + const textSource = this._textScanner.getCurrentTextSource(); + if ( + textSource !== null && + this._popup !== null && + await this._popup.isVisible() + ) { + this._showPopupContent(textSource, null); + } + } + + _signalFrontendReady(targetFrameId=null) { + const params = {frameId: this._frameId}; + if (targetFrameId === null) { + api.broadcastTab('frontendReady', params); + } else { + api.sendMessageToFrame(targetFrameId, 'frontendReady', params); + } + } + + async _waitForFrontendReady(frameId) { + const promise = yomichan.getTemporaryListenerResult( + chrome.runtime.onMessage, + ({action, params}, {resolve}) => { + if ( + action === 'frontendReady' && + params.frameId === frameId + ) { + resolve(); + } + }, + 10000 + ); + api.broadcastTab('requestFrontendReadyBroadcast', {frameId: this._frameId}); + await promise; + } + + _getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) { + switch (this._pageType) { + case 'web': return preventMiddleMouseOptions.onWebPages; + case 'popup': return preventMiddleMouseOptions.onPopupPages; + case 'search': return preventMiddleMouseOptions.onSearchPages; + default: return false; + } + } + + async _getOptionsContext() { + let optionsContext = this._optionsContextOverride; + if (optionsContext === null) { + optionsContext = (await this._getSearchContext()).optionsContext; + } + return optionsContext; + } + + async _getSearchContext() { + let url = window.location.href; + let documentTitle = document.title; + if (this._useProxyPopup) { + try { + ({url, documentTitle} = await api.crossFrame.invoke(this._parentFrameId, 'getPageInfo', {})); + } catch (e) { + // NOP + } + } + + let optionsContext = this._optionsContextOverride; + if (optionsContext === null) { + optionsContext = {depth: this._depth, url}; + } + + return { + optionsContext, + detail: {documentTitle} + }; + } + + async _scanSelectedText() { + const range = this._getFirstNonEmptySelectionRange(); + if (range === null) { return false; } + const source = new TextSourceRange(range, range.toString(), null, null); + await this._textScanner.search(source, {focus: true}); + return true; + } + + _getFirstNonEmptySelectionRange() { + const selection = window.getSelection(); + for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { + const range = selection.getRangeAt(i); + if (range.toString().length > 0) { + return range; + } + } + return null; + } +} diff --git a/ext/js/app/popup-factory.js b/ext/js/app/popup-factory.js new file mode 100644 index 00000000..7571d7ab --- /dev/null +++ b/ext/js/app/popup-factory.js @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * FrameOffsetForwarder + * Popup + * PopupProxy + * PopupWindow + * api + */ + +class PopupFactory { + constructor(frameId) { + this._frameId = frameId; + this._frameOffsetForwarder = new FrameOffsetForwarder(frameId); + this._popups = new Map(); + this._allPopupVisibilityTokenMap = new Map(); + } + + // Public functions + + prepare() { + this._frameOffsetForwarder.prepare(); + api.crossFrame.registerHandlers([ + ['getOrCreatePopup', {async: true, handler: this._onApiGetOrCreatePopup.bind(this)}], + ['setOptionsContext', {async: true, handler: this._onApiSetOptionsContext.bind(this)}], + ['hide', {async: false, handler: this._onApiHide.bind(this)}], + ['isVisible', {async: true, handler: this._onApiIsVisibleAsync.bind(this)}], + ['setVisibleOverride', {async: true, handler: this._onApiSetVisibleOverride.bind(this)}], + ['clearVisibleOverride', {async: true, handler: this._onApiClearVisibleOverride.bind(this)}], + ['containsPoint', {async: true, handler: this._onApiContainsPoint.bind(this)}], + ['showContent', {async: true, handler: this._onApiShowContent.bind(this)}], + ['setCustomCss', {async: false, handler: this._onApiSetCustomCss.bind(this)}], + ['clearAutoPlayTimer', {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}], + ['setContentScale', {async: false, handler: this._onApiSetContentScale.bind(this)}], + ['updateTheme', {async: false, handler: this._onApiUpdateTheme.bind(this)}], + ['setCustomOuterCss', {async: false, handler: this._onApiSetCustomOuterCss.bind(this)}], + ['popup.getFrameSize', {async: true, handler: this._onApiGetFrameSize.bind(this)}], + ['popup.setFrameSize', {async: true, handler: this._onApiSetFrameSize.bind(this)}] + ]); + } + + async getOrCreatePopup({ + frameId=null, + id=null, + parentPopupId=null, + depth=null, + popupWindow=false, + childrenSupported=false + }) { + // Find by existing id + if (id !== null) { + const popup = this._popups.get(id); + if (typeof popup !== 'undefined') { + return popup; + } + } + + // Find by existing parent id + let parent = null; + if (parentPopupId !== null) { + parent = this._popups.get(parentPopupId); + if (typeof parent !== 'undefined') { + const popup = parent.child; + if (popup !== null) { + return popup; + } + } else { + parent = null; + } + } + + // Depth + if (parent !== null) { + if (depth !== null) { + throw new Error('Depth cannot be set when parent exists'); + } + depth = parent.depth + 1; + } else if (depth === null) { + depth = 0; + } + + if (popupWindow) { + // New unique id + if (id === null) { + id = generateId(16); + } + const popup = new PopupWindow({ + id, + depth, + frameId: this._frameId + }); + this._popups.set(id, popup); + return popup; + } else if (frameId === this._frameId) { + // New unique id + if (id === null) { + id = generateId(16); + } + const popup = new Popup({ + id, + depth, + frameId: this._frameId, + childrenSupported + }); + if (parent !== null) { + if (parent.child !== null) { + throw new Error('Parent popup already has a child'); + } + popup.parent = parent; + parent.child = popup; + } + this._popups.set(id, popup); + popup.prepare(); + return popup; + } else { + if (frameId === null) { + throw new Error('Invalid frameId'); + } + const useFrameOffsetForwarder = (parentPopupId === null); + ({id, depth, frameId} = await api.crossFrame.invoke(frameId, 'getOrCreatePopup', { + id, + parentPopupId, + frameId, + childrenSupported + })); + const popup = new PopupProxy({ + id, + depth, + frameId, + frameOffsetForwarder: useFrameOffsetForwarder ? this._frameOffsetForwarder : null + }); + this._popups.set(id, popup); + return popup; + } + } + + async setAllVisibleOverride(value, priority) { + const promises = []; + const errors = []; + for (const popup of this._popups.values()) { + const promise = popup.setVisibleOverride(value, priority) + .then( + (token) => ({popup, token}), + (error) => { errors.push(error); return null; } + ); + promises.push(promise); + } + + const results = (await Promise.all(promises)).filter(({token}) => token !== null); + + if (errors.length === 0) { + const token = generateId(16); + this._allPopupVisibilityTokenMap.set(token, results); + return token; + } + + // Revert on error + await this._revertPopupVisibilityOverrides(results); + throw errors[0]; + } + + async clearAllVisibleOverride(token) { + const results = this._allPopupVisibilityTokenMap.get(token); + if (typeof results === 'undefined') { return false; } + + this._allPopupVisibilityTokenMap.delete(token); + await this._revertPopupVisibilityOverrides(results); + return true; + } + + // API message handlers + + async _onApiGetOrCreatePopup(details) { + const popup = await this.getOrCreatePopup(details); + return { + id: popup.id, + depth: popup.depth, + frameId: popup.frameId + }; + } + + async _onApiSetOptionsContext({id, optionsContext, source}) { + const popup = this._getPopup(id); + return await popup.setOptionsContext(optionsContext, source); + } + + _onApiHide({id, changeFocus}) { + const popup = this._getPopup(id); + return popup.hide(changeFocus); + } + + async _onApiIsVisibleAsync({id}) { + const popup = this._getPopup(id); + return await popup.isVisible(); + } + + async _onApiSetVisibleOverride({id, value, priority}) { + const popup = this._getPopup(id); + return await popup.setVisibleOverride(value, priority); + } + + async _onApiClearVisibleOverride({id, token}) { + const popup = this._getPopup(id); + return await popup.clearVisibleOverride(token); + } + + async _onApiContainsPoint({id, x, y}) { + const popup = this._getPopup(id); + [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y); + return await popup.containsPoint(x, y); + } + + async _onApiShowContent({id, details, displayDetails}) { + const popup = this._getPopup(id); + if (!this._popupCanShow(popup)) { return; } + + const {elementRect} = details; + if (typeof elementRect !== 'undefined') { + details.elementRect = this._convertJsonRectToDOMRect(popup, elementRect); + } + + return await popup.showContent(details, displayDetails); + } + + _onApiSetCustomCss({id, css}) { + const popup = this._getPopup(id); + return popup.setCustomCss(css); + } + + _onApiClearAutoPlayTimer({id}) { + const popup = this._getPopup(id); + return popup.clearAutoPlayTimer(); + } + + _onApiSetContentScale({id, scale}) { + const popup = this._getPopup(id); + return popup.setContentScale(scale); + } + + _onApiUpdateTheme({id}) { + const popup = this._getPopup(id); + return popup.updateTheme(); + } + + _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) { + const popup = this._getPopup(id); + return popup.setCustomOuterCss(css, useWebExtensionApi); + } + + async _onApiGetFrameSize({id}) { + const popup = this._getPopup(id); + return await popup.getFrameSize(); + } + + async _onApiSetFrameSize({id, width, height}) { + const popup = this._getPopup(id); + return await popup.setFrameSize(width, height); + } + + // Private functions + + _getPopup(id) { + const popup = this._popups.get(id); + if (typeof popup === 'undefined') { + throw new Error(`Invalid popup ID ${id}`); + } + return popup; + } + + _convertJsonRectToDOMRect(popup, jsonRect) { + const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); + return new DOMRect(x, y, jsonRect.width, jsonRect.height); + } + + _convertPopupPointToRootPagePoint(popup, x, y) { + const parent = popup.parent; + if (parent !== null) { + const popupRect = parent.getFrameRect(); + x += popupRect.x; + y += popupRect.y; + } + return [x, y]; + } + + _popupCanShow(popup) { + const parent = popup.parent; + return parent === null || parent.isVisibleSync(); + } + + async _revertPopupVisibilityOverrides(overrides) { + const promises = []; + for (const value of overrides) { + if (value === null) { continue; } + const {popup, token} = value; + const promise = popup.clearVisibleOverride(token) + .then( + (v) => v, + () => false + ); + promises.push(promise); + } + return await Promise.all(promises); + } +} diff --git a/ext/js/app/popup-proxy.js b/ext/js/app/popup-proxy.js new file mode 100644 index 00000000..b2e81824 --- /dev/null +++ b/ext/js/app/popup-proxy.js @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * api + */ + +class PopupProxy extends EventDispatcher { + constructor({ + id, + depth, + frameId, + frameOffsetForwarder + }) { + super(); + this._id = id; + this._depth = depth; + this._frameId = frameId; + this._frameOffsetForwarder = frameOffsetForwarder; + + this._frameOffset = [0, 0]; + this._frameOffsetPromise = null; + this._frameOffsetUpdatedAt = null; + this._frameOffsetExpireTimeout = 1000; + } + + // Public properties + + get id() { + return this._id; + } + + get parent() { + return null; + } + + set parent(value) { + throw new Error('Not supported on PopupProxy'); + } + + get child() { + return null; + } + + set child(value) { + throw new Error('Not supported on PopupProxy'); + } + + get depth() { + return this._depth; + } + + get frameContentWindow() { + return null; + } + + get container() { + return null; + } + + get frameId() { + return this._frameId; + } + + // Public functions + + setOptionsContext(optionsContext, source) { + return this._invokeSafe('setOptionsContext', {id: this._id, optionsContext, source}); + } + + hide(changeFocus) { + return this._invokeSafe('hide', {id: this._id, changeFocus}); + } + + isVisible() { + return this._invokeSafe('isVisible', {id: this._id}, false); + } + + setVisibleOverride(value, priority) { + return this._invokeSafe('setVisibleOverride', {id: this._id, value, priority}, null); + } + + clearVisibleOverride(token) { + return this._invokeSafe('clearVisibleOverride', {id: this._id, token}, false); + } + + async containsPoint(x, y) { + if (this._frameOffsetForwarder !== null) { + await this._updateFrameOffset(); + [x, y] = this._applyFrameOffset(x, y); + } + return await this._invokeSafe('containsPoint', {id: this._id, x, y}, false); + } + + async showContent(details, displayDetails) { + const {elementRect} = details; + if (typeof elementRect !== 'undefined') { + let {x, y, width, height} = elementRect; + if (this._frameOffsetForwarder !== null) { + await this._updateFrameOffset(); + [x, y] = this._applyFrameOffset(x, y); + } + details.elementRect = {x, y, width, height}; + } + return await this._invokeSafe('showContent', {id: this._id, details, displayDetails}); + } + + setCustomCss(css) { + return this._invokeSafe('setCustomCss', {id: this._id, css}); + } + + clearAutoPlayTimer() { + return this._invokeSafe('clearAutoPlayTimer', {id: this._id}); + } + + setContentScale(scale) { + return this._invokeSafe('setContentScale', {id: this._id, scale}); + } + + isVisibleSync() { + throw new Error('Not supported on PopupProxy'); + } + + updateTheme() { + return this._invokeSafe('updateTheme', {id: this._id}); + } + + setCustomOuterCss(css, useWebExtensionApi) { + return this._invokeSafe('setCustomOuterCss', {id: this._id, css, useWebExtensionApi}); + } + + getFrameRect() { + return new DOMRect(0, 0, 0, 0); + } + + getFrameSize() { + return this._invokeSafe('popup.getFrameSize', {id: this._id}, {width: 0, height: 0, valid: false}); + } + + setFrameSize(width, height) { + return this._invokeSafe('popup.setFrameSize', {id: this._id, width, height}); + } + + // Private + + _invoke(action, params={}) { + return api.crossFrame.invoke(this._frameId, action, params); + } + + async _invokeSafe(action, params={}, defaultReturnValue) { + try { + return await this._invoke(action, params); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { throw e; } + return defaultReturnValue; + } + } + + async _updateFrameOffset() { + const now = Date.now(); + const firstRun = this._frameOffsetUpdatedAt === null; + const expired = firstRun || this._frameOffsetUpdatedAt < now - this._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._frameOffsetForwarder.getOffset(); + try { + let offset = null; + try { + offset = await this._frameOffsetPromise; + } catch (e) { + // NOP + } + this._frameOffset = offset !== null ? offset : [0, 0]; + if (offset === null) { + this.trigger('offsetNotFound'); + return; + } + this._frameOffsetUpdatedAt = now; + } catch (e) { + yomichan.logError(e); + } finally { + this._frameOffsetPromise = null; + } + } + + _applyFrameOffset(x, y) { + const [offsetX, offsetY] = this._frameOffset; + return [x + offsetX, y + offsetY]; + } +} diff --git a/ext/js/app/popup-window.js b/ext/js/app/popup-window.js new file mode 100644 index 00000000..5fa0c647 --- /dev/null +++ b/ext/js/app/popup-window.js @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * api + */ + +class PopupWindow extends EventDispatcher { + constructor({ + id, + depth, + frameId + }) { + super(); + this._id = id; + this._depth = depth; + this._frameId = frameId; + this._popupTabId = null; + } + + // Public properties + + get id() { + return this._id; + } + + get parent() { + return null; + } + + set parent(value) { + throw new Error('Not supported on PopupProxy'); + } + + get child() { + return null; + } + + set child(value) { + throw new Error('Not supported on PopupProxy'); + } + + get depth() { + return this._depth; + } + + get frameContentWindow() { + return null; + } + + get container() { + return null; + } + + get frameId() { + return this._frameId; + } + + + // Public functions + + setOptionsContext(optionsContext, source) { + return this._invoke(false, 'setOptionsContext', {id: this._id, optionsContext, source}); + } + + hide(_changeFocus) { + // NOP + } + + async isVisible() { + return (this._popupTabId !== null && await api.isTabSearchPopup(this._popupTabId)); + } + + async setVisibleOverride(_value, _priority) { + return null; + } + + clearVisibleOverride(_token) { + return false; + } + + async containsPoint(_x, _y) { + return false; + } + + async showContent(_details, displayDetails) { + if (displayDetails === null) { return; } + await this._invoke(true, 'setContent', {id: this._id, details: displayDetails}); + } + + setCustomCss(css) { + return this._invoke(false, 'setCustomCss', {id: this._id, css}); + } + + clearAutoPlayTimer() { + return this._invoke(false, 'clearAutoPlayTimer', {id: this._id}); + } + + setContentScale(_scale) { + // NOP + } + + isVisibleSync() { + throw new Error('Not supported on PopupWindow'); + } + + updateTheme() { + // NOP + } + + async setCustomOuterCss(_css, _useWebExtensionApi) { + // NOP + } + + getFrameRect() { + return new DOMRect(0, 0, 0, 0); + } + + async getFrameSize() { + return {width: 0, height: 0, valid: false}; + } + + async setFrameSize(_width, _height) { + return false; + } + + // Private + + async _invoke(open, action, params={}, defaultReturnValue) { + if (yomichan.isExtensionUnloaded) { + return defaultReturnValue; + } + + const frameId = 0; + if (this._popupTabId !== null) { + try { + return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params}); + } catch (e) { + if (yomichan.isExtensionUnloaded) { + open = false; + } + } + this._popupTabId = null; + } + + if (!open) { + return defaultReturnValue; + } + + const {tabId} = await api.getOrCreateSearchPopup({focus: 'ifCreated'}); + this._popupTabId = tabId; + + return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params}); + } +} diff --git a/ext/js/app/popup.js b/ext/js/app/popup.js new file mode 100644 index 00000000..75b74257 --- /dev/null +++ b/ext/js/app/popup.js @@ -0,0 +1,687 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * DocumentUtil + * FrameClient + * api + * dynamicLoader + */ + +class Popup extends EventDispatcher { + constructor({ + id, + depth, + frameId, + childrenSupported + }) { + super(); + this._id = id; + this._depth = depth; + this._frameId = frameId; + this._childrenSupported = childrenSupported; + this._parent = null; + this._child = null; + this._injectPromise = null; + this._injectPromiseComplete = false; + this._visible = new DynamicProperty(false); + this._options = null; + this._optionsContext = null; + this._contentScale = 1.0; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + + this._frameSizeContentScale = null; + this._frameClient = null; + this._frame = document.createElement('iframe'); + this._frame.className = 'yomichan-popup'; + this._frame.style.width = '0'; + this._frame.style.height = '0'; + + this._container = this._frame; + this._shadow = null; + + this._fullscreenEventListeners = new EventListenerCollection(); + } + + // Public properties + + get id() { + return this._id; + } + + get parent() { + return this._parent; + } + + set parent(value) { + this._parent = value; + } + + get child() { + return this._child; + } + + set child(value) { + this._child = value; + } + + get depth() { + return this._depth; + } + + get frameContentWindow() { + return this._frame.contentWindow; + } + + get container() { + return this._container; + } + + get frameId() { + return this._frameId; + } + + // Public functions + + prepare() { + this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this)); + this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this)); + this._frame.addEventListener('mousedown', (e) => e.stopPropagation()); + this._frame.addEventListener('scroll', (e) => e.stopPropagation()); + this._frame.addEventListener('load', this._onFrameLoad.bind(this)); + this._visible.on('change', this._onVisibleChange.bind(this)); + yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this)); + this._onVisibleChange({value: this.isVisibleSync()}); + } + + async setOptionsContext(optionsContext) { + await this._setOptionsContext(optionsContext); + await this._invokeSafe('setOptionsContext', {optionsContext}); + } + + hide(changeFocus) { + if (!this.isVisibleSync()) { + return; + } + + this._setVisible(false); + if (this._child !== null) { + this._child.hide(false); + } + if (changeFocus) { + this._focusParent(); + } + } + + async isVisible() { + return this.isVisibleSync(); + } + + async setVisibleOverride(value, priority) { + return this._visible.setOverride(value, priority); + } + + async clearVisibleOverride(token) { + return this._visible.clearOverride(token); + } + + async containsPoint(x, y) { + for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup.child) { + const rect = popup.getFrameRect(); + if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { + return true; + } + } + return false; + } + + async showContent(details, displayDetails) { + if (this._options === null) { throw new Error('Options not assigned'); } + + const {optionsContext, elementRect, writingMode} = details; + if (optionsContext !== null) { + await this._setOptionsContextIfDifferent(optionsContext); + } + + if (typeof elementRect !== 'undefined' && typeof writingMode !== 'undefined') { + await this._show(elementRect, writingMode); + } + + if (displayDetails !== null) { + this._invokeSafe('setContent', {details: displayDetails}); + } + } + + setCustomCss(css) { + this._invokeSafe('setCustomCss', {css}); + } + + clearAutoPlayTimer() { + this._invokeSafe('clearAutoPlayTimer'); + } + + setContentScale(scale) { + this._contentScale = scale; + this._frame.style.fontSize = `${scale}px`; + this._invokeSafe('setContentScale', {scale}); + } + + isVisibleSync() { + return this._visible.value; + } + + updateTheme() { + const {popupTheme, popupOuterTheme} = this._options.general; + this._frame.dataset.theme = popupTheme; + this._frame.dataset.outerTheme = popupOuterTheme; + this._frame.dataset.siteColor = this._getSiteColor(); + } + + async setCustomOuterCss(css, useWebExtensionApi) { + let parentNode = null; + const inShadow = (this._shadow !== null); + if (inShadow) { + useWebExtensionApi = false; + parentNode = this._shadow; + } + const node = await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode); + this.trigger('customOuterCssChanged', {node, useWebExtensionApi, inShadow}); + } + + getFrameRect() { + return this._frame.getBoundingClientRect(); + } + + async getFrameSize() { + const rect = this._frame.getBoundingClientRect(); + return {width: rect.width, height: rect.height, valid: true}; + } + + async setFrameSize(width, height) { + this._setFrameSize(width, height); + return true; + } + + // Private functions + + _onFrameMouseOver() { + this.trigger('framePointerOver', {}); + } + + _onFrameMouseOut() { + this.trigger('framePointerOut', {}); + } + + _inject() { + let injectPromise = this._injectPromise; + if (injectPromise === null) { + injectPromise = this._createInjectPromise(); + this._injectPromise = injectPromise; + injectPromise.then( + () => { + if (injectPromise !== this._injectPromise) { return; } + this._injectPromiseComplete = true; + }, + () => { this._resetFrame(); } + ); + } + return injectPromise; + } + + async _createInjectPromise() { + if (this._options === null) { + throw new Error('Options not initialized'); + } + + const {useSecurePopupFrameUrl, usePopupShadowDom} = this._options.general; + + await this._setUpContainer(usePopupShadowDom); + + const setupFrame = (frame) => { + frame.removeAttribute('src'); + frame.removeAttribute('srcdoc'); + this._observeFullscreen(true); + this._onFullscreenChanged(); + const url = chrome.runtime.getURL('/popup.html'); + if (useSecurePopupFrameUrl) { + frame.contentDocument.location.href = url; + } else { + frame.setAttribute('src', url); + } + }; + + const frameClient = new FrameClient(); + this._frameClient = frameClient; + await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame); + + // Configure + await this._invokeSafe('configure', { + depth: this._depth, + parentPopupId: this._id, + parentFrameId: this._frameId, + childrenSupported: this._childrenSupported, + scale: this._contentScale, + optionsContext: this._optionsContext + }); + } + + _onFrameLoad() { + if (!this._injectPromiseComplete) { return; } + this._resetFrame(); + } + + _resetFrame() { + const parent = this._container.parentNode; + if (parent !== null) { + parent.removeChild(this._container); + } + this._frame.removeAttribute('src'); + this._frame.removeAttribute('srcdoc'); + + this._frameClient = null; + this._injectPromise = null; + this._injectPromiseComplete = false; + } + + async _setUpContainer(usePopupShadowDom) { + if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') { + const container = document.createElement('div'); + container.style.setProperty('all', 'initial', 'important'); + const shadow = container.attachShadow({mode: 'closed', delegatesFocus: true}); + shadow.appendChild(this._frame); + + this._container = container; + this._shadow = shadow; + } else { + const frameParentNode = this._frame.parentNode; + if (frameParentNode !== null) { + frameParentNode.removeChild(this._frame); + } + + this._container = this._frame; + this._shadow = null; + } + + await this._injectStyles(); + } + + async _injectStyles() { + try { + await this._injectPopupOuterStylesheet(); + } catch (e) { + // NOP + } + + try { + await this.setCustomOuterCss(this._options.general.customPopupOuterCss, true); + } catch (e) { + // NOP + } + } + + async _injectPopupOuterStylesheet() { + let fileType = 'file'; + let useWebExtensionApi = true; + let parentNode = null; + if (this._shadow !== null) { + fileType = 'file-content'; + useWebExtensionApi = false; + parentNode = this._shadow; + } + await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', fileType, '/css/popup-outer.css', useWebExtensionApi, parentNode); + } + + _observeFullscreen(observe) { + if (!observe) { + this._fullscreenEventListeners.removeAllEventListeners(); + return; + } + + if (this._fullscreenEventListeners.size > 0) { + // Already observing + return; + } + + DocumentUtil.addFullscreenChangeEventListener(this._onFullscreenChanged.bind(this), this._fullscreenEventListeners); + } + + _onFullscreenChanged() { + const parent = this._getFrameParentElement(); + if (parent !== null && this._container.parentNode !== parent) { + parent.appendChild(this._container); + } + } + + async _show(elementRect, writingMode) { + await this._inject(); + + const optionsGeneral = this._options.general; + const {popupDisplayMode} = optionsGeneral; + const frame = this._frame; + const frameRect = frame.getBoundingClientRect(); + + const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); + const scale = this._contentScale; + const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale; + this._frameSizeContentScale = scale; + const getPositionArgs = [ + elementRect, + Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale), + Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale), + viewport, + scale, + optionsGeneral, + writingMode + ]; + let [x, y, width, height, below] = ( + writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? + this._getPositionForHorizontalText(...getPositionArgs) : + this._getPositionForVerticalText(...getPositionArgs) + ); + + frame.dataset.popupDisplayMode = popupDisplayMode; + frame.dataset.below = `${below}`; + + if (popupDisplayMode === 'full-width') { + x = viewport.left; + y = below ? viewport.bottom - height : viewport.top; + width = viewport.right - viewport.left; + } + + frame.style.left = `${x}px`; + frame.style.top = `${y}px`; + this._setFrameSize(width, height); + + this._setVisible(true); + if (this._child !== null) { + this._child.hide(true); + } + } + + _setFrameSize(width, height) { + const {style} = this._frame; + style.width = `${width}px`; + style.height = `${height}px`; + } + + _setVisible(visible) { + this._visible.defaultValue = visible; + } + + _onVisibleChange({value}) { + this._frame.style.setProperty('visibility', value ? 'visible' : 'hidden', 'important'); + } + + _focusParent() { + if (this._parent !== null) { + // Chrome doesn't like focusing iframe without contentWindow. + const contentWindow = this._parent.frameContentWindow; + if (contentWindow !== null) { + contentWindow.focus(); + } + } else { + // Firefox doesn't like focusing window without first blurring the iframe. + // this._frame.contentWindow.blur() doesn't work on Firefox for some reason. + this._frame.blur(); + // This is needed for Chrome. + window.focus(); + } + } + + _getSiteColor() { + const color = [255, 255, 255]; + const {documentElement, body} = document; + if (documentElement !== null) { + this._addColor(color, window.getComputedStyle(documentElement).backgroundColor); + } + if (body !== null) { + this._addColor(color, window.getComputedStyle(body).backgroundColor); + } + const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128); + return dark ? 'dark' : 'light'; + } + + async _invoke(action, params={}) { + const contentWindow = this._frame.contentWindow; + if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; } + + const message = this._frameClient.createMessage({action, params}); + return await api.crossFrame.invoke(this._frameClient.frameId, 'popupMessage', message); + } + + async _invokeSafe(action, params={}, defaultReturnValue) { + try { + return await this._invoke(action, params); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { throw e; } + return defaultReturnValue; + } + } + + _invokeWindow(action, params={}) { + const contentWindow = this._frame.contentWindow; + if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; } + + const message = this._frameClient.createMessage({action, params}); + contentWindow.postMessage(message, this._targetOrigin); + } + + _onExtensionUnloaded() { + this._invokeWindow('extensionUnloaded'); + } + + _getFrameParentElement() { + const defaultParent = document.body; + const fullscreenElement = DocumentUtil.getFullscreenElement(); + if ( + fullscreenElement === null || + fullscreenElement.shadowRoot || + fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + ) { + return defaultParent; + } + + switch (fullscreenElement.nodeName.toUpperCase()) { + case 'IFRAME': + case 'FRAME': + return defaultParent; + } + + return fullscreenElement; + } + + _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { + const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below'); + const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale; + const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale; + + const [x, w] = this._getConstrainedPosition( + elementRect.right - horizontalOffset, + elementRect.left + horizontalOffset, + width, + viewport.left, + viewport.right, + true + ); + const [y, h, below] = this._getConstrainedPositionBinary( + elementRect.top - verticalOffset, + elementRect.bottom + verticalOffset, + height, + viewport.top, + viewport.bottom, + preferBelow + ); + return [x, y, w, h, below]; + } + + _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { + const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); + const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale; + const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale; + + const [x, w] = this._getConstrainedPositionBinary( + elementRect.left - horizontalOffset, + elementRect.right + horizontalOffset, + width, + viewport.left, + viewport.right, + preferRight + ); + const [y, h, below] = this._getConstrainedPosition( + elementRect.bottom - verticalOffset, + elementRect.top + verticalOffset, + height, + viewport.top, + viewport.bottom, + true + ); + return [x, y, w, h, below]; + } + + _isVerticalTextPopupOnRight(positionPreference, writingMode) { + switch (positionPreference) { + case 'before': + return !this._isWritingModeLeftToRight(writingMode); + case 'after': + return this._isWritingModeLeftToRight(writingMode); + case 'left': + return false; + case 'right': + return true; + default: + return false; + } + } + + _isWritingModeLeftToRight(writingMode) { + switch (writingMode) { + case 'vertical-lr': + case 'sideways-lr': + return true; + default: + return false; + } + } + + _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + size = Math.min(size, maxLimit - minLimit); + + let position; + if (after) { + position = Math.max(minLimit, positionAfter); + position = position - Math.max(0, (position + size) - maxLimit); + } else { + position = Math.min(maxLimit, positionBefore) - size; + position = position + Math.max(0, minLimit - position); + } + + return [position, size, after]; + } + + _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + const overflowBefore = minLimit - (positionBefore - size); + const overflowAfter = (positionAfter + size) - maxLimit; + + if (overflowAfter > 0 || overflowBefore > 0) { + after = (overflowAfter < overflowBefore); + } + + let position; + if (after) { + size -= Math.max(0, overflowAfter); + position = Math.max(minLimit, positionAfter); + } else { + size -= Math.max(0, overflowBefore); + position = Math.min(maxLimit, positionBefore) - size; + } + + return [position, size, after]; + } + + _addColor(target, cssColor) { + if (typeof cssColor !== 'string') { return; } + + const color = this._getColorInfo(cssColor); + if (color === null) { return; } + + const a = color[3]; + if (a <= 0.0) { return; } + + const aInv = 1.0 - a; + for (let i = 0; i < 3; ++i) { + target[i] = target[i] * aInv + color[i] * a; + } + } + + _getColorInfo(cssColor) { + const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor); + if (m === null) { return null; } + + const m4 = m[4]; + return [ + Number.parseInt(m[1], 10), + Number.parseInt(m[2], 10), + Number.parseInt(m[3], 10), + m4 ? Math.max(0.0, Math.min(1.0, Number.parseFloat(m4))) : 1.0 + ]; + } + + _getViewport(useVisualViewport) { + const visualViewport = window.visualViewport; + if (visualViewport !== null && typeof visualViewport === 'object') { + const left = visualViewport.offsetLeft; + const top = visualViewport.offsetTop; + const width = visualViewport.width; + const height = visualViewport.height; + if (useVisualViewport) { + return { + left, + top, + right: left + width, + bottom: top + height + }; + } else { + const scale = visualViewport.scale; + return { + left: 0, + top: 0, + right: Math.max(left + width, width * scale), + bottom: Math.max(top + height, height * scale) + }; + } + } + + const body = document.body; + return { + left: 0, + top: 0, + right: (body !== null ? body.clientWidth : 0), + bottom: window.innerHeight + }; + } + + async _setOptionsContext(optionsContext) { + this._optionsContext = optionsContext; + this._options = await api.optionsGet(optionsContext); + this.updateTheme(); + } + + async _setOptionsContextIfDifferent(optionsContext) { + if (deepEqual(this._optionsContext, optionsContext)) { return; } + await this._setOptionsContext(optionsContext); + } +} |