aboutsummaryrefslogtreecommitdiff
path: root/ext/js/app
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/app')
-rw-r--r--ext/js/app/content-script-main.js59
-rw-r--r--ext/js/app/frontend.js691
-rw-r--r--ext/js/app/popup-factory.js319
-rw-r--r--ext/js/app/popup-proxy.js218
-rw-r--r--ext/js/app/popup-window.js169
-rw-r--r--ext/js/app/popup.js687
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);
+ }
+}