aboutsummaryrefslogtreecommitdiff
path: root/ext/js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js')
-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
-rw-r--r--ext/js/comm/frame-ancestry-handler.js269
-rw-r--r--ext/js/comm/frame-offset-forwarder.js70
-rw-r--r--ext/js/display/display.js14
-rw-r--r--ext/js/display/popup-main.js56
-rw-r--r--ext/js/dom/dom-text-scanner.js551
-rw-r--r--ext/js/dom/text-source-element.js139
-rw-r--r--ext/js/dom/text-source-range.js170
13 files changed, 3405 insertions, 7 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);
+ }
+}
diff --git a/ext/js/comm/frame-ancestry-handler.js b/ext/js/comm/frame-ancestry-handler.js
new file mode 100644
index 00000000..b1ed7114
--- /dev/null
+++ b/ext/js/comm/frame-ancestry-handler.js
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 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
+ */
+
+/**
+ * This class is used to return the ancestor frame IDs for the current frame.
+ * This is a workaround to using the `webNavigation.getAllFrames` API, which
+ * would require an additional permission that is otherwise unnecessary.
+ * It is also used to track the correlation between child frame elements and their IDs.
+ */
+class FrameAncestryHandler {
+ /**
+ * Creates a new instance.
+ * @param frameId The frame ID of the current frame the instance is instantiated in.
+ */
+ constructor(frameId) {
+ this._frameId = frameId;
+ this._isPrepared = false;
+ this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo';
+ this._responseMessageIdBase = `${this._requestMessageId}.response.`;
+ this._getFrameAncestryInfoPromise = null;
+ this._childFrameMap = new Map();
+ }
+
+ /**
+ * Gets the frame ID that the instance is instantiated in.
+ */
+ get frameId() {
+ return this._frameId;
+ }
+
+ /**
+ * Initializes event event listening.
+ */
+ prepare() {
+ if (this._isPrepared) { return; }
+ window.addEventListener('message', this._onWindowMessage.bind(this), false);
+ this._isPrepared = true;
+ }
+
+ /**
+ * Returns whether or not this frame is the root frame in the tab.
+ * @returns `true` if it is the root, otherwise `false`.
+ */
+ isRootFrame() {
+ return (window === window.parent);
+ }
+
+ /**
+ * Gets the frame ancestry information for the current frame. If the frame is the
+ * root frame, an empty array is returned. Otherwise, an array of frame IDs is returned,
+ * starting from the nearest ancestor.
+ * @param timeout The maximum time to wait to receive a response to frame information requests.
+ * @returns An array of frame IDs corresponding to the ancestors of the current frame.
+ */
+ async getFrameAncestryInfo() {
+ if (this._getFrameAncestryInfoPromise === null) {
+ this._getFrameAncestryInfoPromise = this._getFrameAncestryInfo(5000);
+ }
+ return await this._getFrameAncestryInfoPromise;
+ }
+
+ /**
+ * Gets the frame element of a child frame given a frame ID.
+ * For this function to work, the `getFrameAncestryInfo` function needs to have
+ * been invoked previously.
+ * @param frameId The frame ID of the child frame to get.
+ * @returns The element corresponding to the frame with ID `frameId`, otherwise `null`.
+ */
+ getChildFrameElement(frameId) {
+ const frameInfo = this._childFrameMap.get(frameId);
+ if (typeof frameInfo === 'undefined') { return null; }
+
+ let {frameElement} = frameInfo;
+ if (typeof frameElement === 'undefined') {
+ frameElement = this._findFrameElementWithContentWindow(frameInfo.window);
+ frameInfo.frameElement = frameElement;
+ }
+
+ return frameElement;
+ }
+
+ // Private
+
+ _getFrameAncestryInfo(timeout=5000) {
+ return new Promise((resolve, reject) => {
+ const targetWindow = window.parent;
+ if (window === targetWindow) {
+ resolve([]);
+ return;
+ }
+
+ const uniqueId = generateId(16);
+ let nonce = generateId(16);
+ const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`;
+ const results = [];
+ let timer = null;
+
+ const cleanup = () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ api.crossFrame.unregisterHandler(responseMessageId);
+ };
+ const onMessage = (params) => {
+ if (params.nonce !== nonce) { return null; }
+
+ // Add result
+ const {frameId, more} = params;
+ results.push(frameId);
+ nonce = generateId(16);
+
+ if (!more) {
+ // Cleanup
+ cleanup();
+
+ // Finish
+ resolve(results);
+ }
+ return {nonce};
+ };
+ const onTimeout = () => {
+ timer = null;
+ cleanup();
+ reject(new Error(`Request for parent frame ID timed out after ${timeout}ms`));
+ };
+ const resetTimeout = () => {
+ if (timer !== null) { clearTimeout(timer); }
+ timer = setTimeout(onTimeout, timeout);
+ };
+
+ // Start
+ api.crossFrame.registerHandlers([[responseMessageId, {async: false, handler: onMessage}]]);
+ resetTimeout();
+ const frameId = this._frameId;
+ this._requestFrameInfo(targetWindow, frameId, frameId, uniqueId, nonce);
+ });
+ }
+
+ _onWindowMessage(event) {
+ const {source} = event;
+ if (source === window || source.parent !== window) { return; }
+
+ const {data} = event;
+ if (
+ typeof data === 'object' &&
+ data !== null &&
+ data.action === this._requestMessageId
+ ) {
+ this._onRequestFrameInfo(data.params, source);
+ }
+ }
+
+ async _onRequestFrameInfo(params, source) {
+ try {
+ let {originFrameId, childFrameId, uniqueId, nonce} = params;
+ if (
+ !this._isNonNegativeInteger(originFrameId) ||
+ typeof uniqueId !== 'string' ||
+ typeof nonce !== 'string'
+ ) {
+ return;
+ }
+
+ const frameId = this._frameId;
+ const {parent} = window;
+ const more = (window !== parent);
+ const responseParams = {frameId, nonce, more};
+ const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`;
+
+ try {
+ const response = await api.crossFrame.invoke(originFrameId, responseMessageId, responseParams);
+ if (response === null) { return; }
+ nonce = response.nonce;
+ } catch (e) {
+ return;
+ }
+
+ if (!this._childFrameMap.has(childFrameId)) {
+ this._childFrameMap.set(childFrameId, {window: source, frameElement: void 0});
+ }
+
+ if (more) {
+ this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, nonce);
+ }
+ } catch (e) {
+ // NOP
+ }
+ }
+
+ _requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) {
+ targetWindow.postMessage({
+ action: this._requestMessageId,
+ params: {originFrameId, childFrameId, uniqueId, nonce}
+ }, '*');
+ }
+
+ _isNonNegativeInteger(value) {
+ return (
+ typeof value === 'number' &&
+ Number.isFinite(value) &&
+ value >= 0 &&
+ Math.floor(value) === value
+ );
+ }
+
+ _findFrameElementWithContentWindow(contentWindow) {
+ // Check frameElement, for non-null same-origin frames
+ try {
+ const {frameElement} = contentWindow;
+ if (frameElement !== null) { return frameElement; }
+ } catch (e) {
+ // NOP
+ }
+
+ // Check frames
+ const frameTypes = ['iframe', 'frame', 'embed'];
+ for (const frameType of frameTypes) {
+ for (const frame of document.getElementsByTagName(frameType)) {
+ if (frame.contentWindow === contentWindow) {
+ return frame;
+ }
+ }
+ }
+
+ // Check for shadow roots
+ const rootElements = [document.documentElement];
+ while (rootElements.length > 0) {
+ const rootElement = rootElements.shift();
+ const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
+ while (walker.nextNode()) {
+ const element = walker.currentNode;
+
+ if (element.contentWindow === contentWindow) {
+ return element;
+ }
+
+ const shadowRoot = (
+ element.shadowRoot ||
+ element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
+ );
+ if (shadowRoot) {
+ rootElements.push(shadowRoot);
+ }
+ }
+ }
+
+ // Not found
+ return null;
+ }
+}
diff --git a/ext/js/comm/frame-offset-forwarder.js b/ext/js/comm/frame-offset-forwarder.js
new file mode 100644
index 00000000..0a0b4a18
--- /dev/null
+++ b/ext/js/comm/frame-offset-forwarder.js
@@ -0,0 +1,70 @@
+/*
+ * 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
+ * FrameAncestryHandler
+ * api
+ */
+
+class FrameOffsetForwarder {
+ constructor(frameId) {
+ this._frameId = frameId;
+ this._frameAncestryHandler = new FrameAncestryHandler(frameId);
+ }
+
+ prepare() {
+ this._frameAncestryHandler.prepare();
+ api.crossFrame.registerHandlers([
+ ['FrameOffsetForwarder.getChildFrameRect', {async: false, handler: this._onMessageGetChildFrameRect.bind(this)}]
+ ]);
+ }
+
+ async getOffset() {
+ if (this._frameAncestryHandler.isRootFrame()) {
+ return [0, 0];
+ }
+
+ const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo();
+
+ let childFrameId = this._frameId;
+ const promises = [];
+ for (const frameId of ancestorFrameIds) {
+ promises.push(api.crossFrame.invoke(frameId, 'FrameOffsetForwarder.getChildFrameRect', {frameId: childFrameId}));
+ childFrameId = frameId;
+ }
+
+ const results = await Promise.all(promises);
+
+ let xOffset = 0;
+ let yOffset = 0;
+ for (const {x, y} of results) {
+ xOffset += x;
+ yOffset += y;
+ }
+ return [xOffset, yOffset];
+ }
+
+ // Private
+
+ _onMessageGetChildFrameRect({frameId}) {
+ const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId);
+ if (frameElement === null) { return null; }
+
+ const {x, y, width, height} = frameElement.getBoundingClientRect();
+ return {x, y, width, height};
+ }
+}
diff --git a/ext/js/display/display.js b/ext/js/display/display.js
index ffadd055..c522fe14 100644
--- a/ext/js/display/display.js
+++ b/ext/js/display/display.js
@@ -1573,13 +1573,13 @@ class Display extends EventDispatcher {
await dynamicLoader.loadScripts([
'/js/language/text-scanner.js',
'/js/comm/frame-client.js',
- '/fg/js/popup.js',
- '/fg/js/popup-proxy.js',
- '/fg/js/popup-window.js',
- '/fg/js/popup-factory.js',
- '/fg/js/frame-ancestry-handler.js',
- '/fg/js/frame-offset-forwarder.js',
- '/fg/js/frontend.js'
+ '/js/app/popup.js',
+ '/js/app/popup-proxy.js',
+ '/js/app/popup-window.js',
+ '/js/app/popup-factory.js',
+ '/js/comm/frame-ancestry-handler.js',
+ '/js/comm/frame-offset-forwarder.js',
+ '/js/app/frontend.js'
]);
const popupFactory = new PopupFactory(this._frameId);
diff --git a/ext/js/display/popup-main.js b/ext/js/display/popup-main.js
new file mode 100644
index 00000000..7c048b62
--- /dev/null
+++ b/ext/js/display/popup-main.js
@@ -0,0 +1,56 @@
+/*
+ * 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
+ * Display
+ * DisplayProfileSelection
+ * DocumentFocusController
+ * HotkeyHandler
+ * JapaneseUtil
+ * api
+ */
+
+(async () => {
+ try {
+ const documentFocusController = new DocumentFocusController();
+ documentFocusController.prepare();
+
+ api.forwardLogsToBackend();
+ await yomichan.backendReady();
+
+ const {tabId, frameId} = await api.frameInformationGet();
+
+ const japaneseUtil = new JapaneseUtil(null);
+
+ const hotkeyHandler = new HotkeyHandler();
+ hotkeyHandler.prepare();
+
+ const display = new Display(tabId, frameId, 'popup', japaneseUtil, documentFocusController, hotkeyHandler);
+ await display.prepare();
+
+ const displayProfileSelection = new DisplayProfileSelection(display);
+ displayProfileSelection.prepare();
+
+ display.initializeState();
+
+ document.documentElement.dataset.loaded = 'true';
+
+ yomichan.ready();
+ } catch (e) {
+ yomichan.logError(e);
+ }
+})();
diff --git a/ext/js/dom/dom-text-scanner.js b/ext/js/dom/dom-text-scanner.js
new file mode 100644
index 00000000..71e74fc3
--- /dev/null
+++ b/ext/js/dom/dom-text-scanner.js
@@ -0,0 +1,551 @@
+/*
+ * 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/>.
+ */
+
+/**
+ * A class used to scan text in a document.
+ */
+class DOMTextScanner {
+ /**
+ * Creates a new instance of a DOMTextScanner.
+ * @param node The DOM Node to start at.
+ * @param offset The character offset in to start at when node is a text node.
+ * Use 0 for non-text nodes.
+ */
+ constructor(node, offset, forcePreserveWhitespace=false, generateLayoutContent=true) {
+ const ruby = DOMTextScanner.getParentRubyElement(node);
+ const resetOffset = (ruby !== null);
+ if (resetOffset) { node = ruby; }
+
+ this._node = node;
+ this._offset = offset;
+ this._content = '';
+ this._remainder = 0;
+ this._resetOffset = resetOffset;
+ this._newlines = 0;
+ this._lineHasWhitespace = false;
+ this._lineHasContent = false;
+ this._forcePreserveWhitespace = forcePreserveWhitespace;
+ this._generateLayoutContent = generateLayoutContent;
+ }
+
+ /**
+ * Gets the current node being scanned.
+ * @returns A DOM Node.
+ */
+ get node() {
+ return this._node;
+ }
+
+ /**
+ * Gets the current offset corresponding to the node being scanned.
+ * This value is only applicable for text nodes.
+ * @returns An integer.
+ */
+ get offset() {
+ return this._offset;
+ }
+
+ /**
+ * Gets the remaining number of characters that weren't scanned in the last seek() call.
+ * This value is usually 0 unless the end of the document was reached.
+ * @returns An integer.
+ */
+ get remainder() {
+ return this._remainder;
+ }
+
+ /**
+ * Gets the accumulated content string resulting from calls to seek().
+ * @returns A string.
+ */
+ get content() {
+ return this._content;
+ }
+
+ /**
+ * Seeks a given length in the document and accumulates the text content.
+ * @param length A positive or negative integer corresponding to how many characters
+ * should be added to content. Content is only added to the accumulation string,
+ * never removed, so mixing seek calls with differently signed length values
+ * may give unexpected results.
+ * @returns this
+ */
+ seek(length) {
+ const forward = (length >= 0);
+ this._remainder = (forward ? length : -length);
+ if (length === 0) { return this; }
+
+ const TEXT_NODE = Node.TEXT_NODE;
+ const ELEMENT_NODE = Node.ELEMENT_NODE;
+
+ const generateLayoutContent = this._generateLayoutContent;
+ let node = this._node;
+ let lastNode = node;
+ let resetOffset = this._resetOffset;
+ let newlines = 0;
+ while (node !== null) {
+ let enterable = false;
+ const nodeType = node.nodeType;
+
+ if (nodeType === TEXT_NODE) {
+ lastNode = node;
+ if (!(
+ forward ?
+ this._seekTextNodeForward(node, resetOffset) :
+ this._seekTextNodeBackward(node, resetOffset)
+ )) {
+ // Length reached
+ break;
+ }
+ } else if (nodeType === ELEMENT_NODE) {
+ lastNode = node;
+ this._offset = 0;
+ [enterable, newlines] = DOMTextScanner.getElementSeekInfo(node);
+ if (newlines > this._newlines && generateLayoutContent) {
+ this._newlines = newlines;
+ }
+ }
+
+ const exitedNodes = [];
+ node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes);
+
+ for (const exitedNode of exitedNodes) {
+ if (exitedNode.nodeType !== ELEMENT_NODE) { continue; }
+ newlines = DOMTextScanner.getElementSeekInfo(exitedNode)[1];
+ if (newlines > this._newlines && generateLayoutContent) {
+ this._newlines = newlines;
+ }
+ }
+
+ resetOffset = true;
+ }
+
+ this._node = lastNode;
+ this._resetOffset = resetOffset;
+
+ return this;
+ }
+
+ // Private
+
+ /**
+ * Seeks forward in a text node.
+ * @param textNode The text node to use.
+ * @param resetOffset Whether or not the text offset should be reset.
+ * @returns true if scanning should continue, or false if the scan length has been reached.
+ */
+ _seekTextNodeForward(textNode, resetOffset) {
+ const nodeValue = textNode.nodeValue;
+ const nodeValueLength = nodeValue.length;
+ const [preserveNewlines, preserveWhitespace] = (
+ this._forcePreserveWhitespace ?
+ [true, true] :
+ DOMTextScanner.getWhitespaceSettings(textNode)
+ );
+
+ let lineHasWhitespace = this._lineHasWhitespace;
+ let lineHasContent = this._lineHasContent;
+ let content = this._content;
+ let offset = resetOffset ? 0 : this._offset;
+ let remainder = this._remainder;
+ let newlines = this._newlines;
+
+ while (offset < nodeValueLength) {
+ const char = nodeValue[offset];
+ const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
+ ++offset;
+
+ if (charAttributes === 0) {
+ // Character should be ignored
+ continue;
+ } else if (charAttributes === 1) {
+ // Character is collapsable whitespace
+ lineHasWhitespace = true;
+ } else {
+ // Character should be added to the content
+ if (newlines > 0) {
+ if (content.length > 0) {
+ const useNewlineCount = Math.min(remainder, newlines);
+ content += '\n'.repeat(useNewlineCount);
+ remainder -= useNewlineCount;
+ newlines -= useNewlineCount;
+ } else {
+ newlines = 0;
+ }
+ lineHasContent = false;
+ lineHasWhitespace = false;
+ if (remainder <= 0) {
+ --offset; // Revert character offset
+ break;
+ }
+ }
+
+ lineHasContent = (charAttributes === 2); // 3 = character is a newline
+
+ if (lineHasWhitespace) {
+ if (lineHasContent) {
+ content += ' ';
+ lineHasWhitespace = false;
+ if (--remainder <= 0) {
+ --offset; // Revert character offset
+ break;
+ }
+ } else {
+ lineHasWhitespace = false;
+ }
+ }
+
+ content += char;
+
+ if (--remainder <= 0) { break; }
+ }
+ }
+
+ this._lineHasWhitespace = lineHasWhitespace;
+ this._lineHasContent = lineHasContent;
+ this._content = content;
+ this._offset = offset;
+ this._remainder = remainder;
+ this._newlines = newlines;
+
+ return (remainder > 0);
+ }
+
+ /**
+ * Seeks backward in a text node.
+ * This function is nearly the same as _seekTextNodeForward, with the following differences:
+ * - Iteration condition is reversed to check if offset is greater than 0.
+ * - offset is reset to nodeValueLength instead of 0.
+ * - offset is decremented instead of incremented.
+ * - offset is decremented before getting the character.
+ * - offset is reverted by incrementing instead of decrementing.
+ * - content string is prepended instead of appended.
+ * @param textNode The text node to use.
+ * @param resetOffset Whether or not the text offset should be reset.
+ * @returns true if scanning should continue, or false if the scan length has been reached.
+ */
+ _seekTextNodeBackward(textNode, resetOffset) {
+ const nodeValue = textNode.nodeValue;
+ const nodeValueLength = nodeValue.length;
+ const [preserveNewlines, preserveWhitespace] = (
+ this._forcePreserveWhitespace ?
+ [true, true] :
+ DOMTextScanner.getWhitespaceSettings(textNode)
+ );
+
+ let lineHasWhitespace = this._lineHasWhitespace;
+ let lineHasContent = this._lineHasContent;
+ let content = this._content;
+ let offset = resetOffset ? nodeValueLength : this._offset;
+ let remainder = this._remainder;
+ let newlines = this._newlines;
+
+ while (offset > 0) {
+ --offset;
+ const char = nodeValue[offset];
+ const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
+
+ if (charAttributes === 0) {
+ // Character should be ignored
+ continue;
+ } else if (charAttributes === 1) {
+ // Character is collapsable whitespace
+ lineHasWhitespace = true;
+ } else {
+ // Character should be added to the content
+ if (newlines > 0) {
+ if (content.length > 0) {
+ const useNewlineCount = Math.min(remainder, newlines);
+ content = '\n'.repeat(useNewlineCount) + content;
+ remainder -= useNewlineCount;
+ newlines -= useNewlineCount;
+ } else {
+ newlines = 0;
+ }
+ lineHasContent = false;
+ lineHasWhitespace = false;
+ if (remainder <= 0) {
+ ++offset; // Revert character offset
+ break;
+ }
+ }
+
+ lineHasContent = (charAttributes === 2); // 3 = character is a newline
+
+ if (lineHasWhitespace) {
+ if (lineHasContent) {
+ content = ' ' + content;
+ lineHasWhitespace = false;
+ if (--remainder <= 0) {
+ ++offset; // Revert character offset
+ break;
+ }
+ } else {
+ lineHasWhitespace = false;
+ }
+ }
+
+ content = char + content;
+
+ if (--remainder <= 0) { break; }
+ }
+ }
+
+ this._lineHasWhitespace = lineHasWhitespace;
+ this._lineHasContent = lineHasContent;
+ this._content = content;
+ this._offset = offset;
+ this._remainder = remainder;
+ this._newlines = newlines;
+
+ return (remainder > 0);
+ }
+
+ // Static helpers
+
+ /**
+ * Gets the next node in the document for a specified scanning direction.
+ * @param node The current DOM Node.
+ * @param forward Whether to scan forward in the document or backward.
+ * @param visitChildren Whether the children of the current node should be visited.
+ * @param exitedNodes An array which stores nodes which were exited.
+ * @returns The next node in the document, or null if there is no next node.
+ */
+ static getNextNode(node, forward, visitChildren, exitedNodes) {
+ let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null;
+ if (next === null) {
+ while (true) {
+ exitedNodes.push(node);
+
+ next = (forward ? node.nextSibling : node.previousSibling);
+ if (next !== null) { break; }
+
+ next = node.parentNode;
+ if (next === null) { break; }
+
+ node = next;
+ }
+ }
+ return next;
+ }
+
+ /**
+ * Gets the parent element of a given Node.
+ * @param node The node to check.
+ * @returns The parent element if one exists, otherwise null.
+ */
+ static getParentElement(node) {
+ while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
+ node = node.parentNode;
+ }
+ return node;
+ }
+
+ /**
+ * Gets the parent <ruby> element of a given node, if one exists. For efficiency purposes,
+ * this only checks the immediate parent elements and does not check all ancestors, so
+ * there are cases where the node may be in a ruby element but it is not returned.
+ * @param node The node to check.
+ * @returns A <ruby> node if the input node is contained in one, otherwise null.
+ */
+ static getParentRubyElement(node) {
+ node = DOMTextScanner.getParentElement(node);
+ if (node !== null && node.nodeName.toUpperCase() === 'RT') {
+ node = node.parentNode;
+ if (node !== null && node.nodeName.toUpperCase() === 'RUBY') {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @returns [enterable: boolean, newlines: integer]
+ * The enterable value indicates whether the content of this node should be entered.
+ * The newlines value corresponds to the number of newline characters that should be added.
+ * 1 newline corresponds to a simple new line in the layout.
+ * 2 newlines corresponds to a significant visual distinction since the previous content.
+ */
+ static getElementSeekInfo(element) {
+ let enterable = true;
+ switch (element.nodeName.toUpperCase()) {
+ case 'HEAD':
+ case 'RT':
+ case 'SCRIPT':
+ case 'STYLE':
+ return [false, 0];
+ case 'BR':
+ return [false, 1];
+ case 'TEXTAREA':
+ case 'INPUT':
+ case 'BUTTON':
+ enterable = false;
+ break;
+ }
+
+ const style = window.getComputedStyle(element);
+ const display = style.display;
+
+ const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style));
+ let newlines = 0;
+
+ if (!visible) {
+ enterable = false;
+ } else {
+ switch (style.position) {
+ case 'absolute':
+ case 'fixed':
+ case 'sticky':
+ newlines = 2;
+ break;
+ }
+ if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) {
+ newlines = 1;
+ }
+ }
+
+ return [enterable, newlines];
+ }
+
+ /**
+ * Gets information about how whitespace characters are treated.
+ * @param textNode The Text node to check.
+ * @returns [preserveNewlines: boolean, preserveWhitespace: boolean]
+ * The value of preserveNewlines indicates whether or not newline characters are treated as line breaks.
+ * The value of preserveWhitespace indicates whether or not sequences of whitespace characters are collapsed.
+ */
+ static getWhitespaceSettings(textNode) {
+ const element = DOMTextScanner.getParentElement(textNode);
+ if (element !== null) {
+ const style = window.getComputedStyle(element);
+ switch (style.whiteSpace) {
+ case 'pre':
+ case 'pre-wrap':
+ case 'break-spaces':
+ return [true, true];
+ case 'pre-line':
+ return [true, false];
+ }
+ }
+ return [false, false];
+ }
+
+ /**
+ * Gets attributes for the specified character.
+ * @param character A string containing a single character.
+ * @returns An integer representing the attributes of the character.
+ * 0: Character should be ignored.
+ * 1: Character is collapsable whitespace.
+ * 2: Character should be added to the content.
+ * 3: Character should be added to the content and is a newline.
+ */
+ static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) {
+ switch (character.charCodeAt(0)) {
+ case 0x09: // Tab ('\t')
+ case 0x0c: // Form feed ('\f')
+ case 0x0d: // Carriage return ('\r')
+ case 0x20: // Space (' ')
+ return preserveWhitespace ? 2 : 1;
+ case 0x0a: // Line feed ('\n')
+ return preserveNewlines ? 3 : 1;
+ case 0x200c: // Zero-width non-joiner ('\u200c')
+ return 0;
+ default: // Other
+ return 2;
+ }
+ }
+
+ /**
+ * Checks whether a given style is visible or not.
+ * This function does not check style.display === 'none'.
+ * @param style An object implementing the CSSStyleDeclaration interface.
+ * @returns true if the style should result in an element being visible, otherwise false.
+ */
+ static isStyleVisible(style) {
+ return !(
+ style.visibility === 'hidden' ||
+ parseFloat(style.opacity) <= 0 ||
+ parseFloat(style.fontSize) <= 0 ||
+ (
+ !DOMTextScanner.isStyleSelectable(style) &&
+ (
+ DOMTextScanner.isCSSColorTransparent(style.color) ||
+ DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor)
+ )
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given style is selectable or not.
+ * @param style An object implementing the CSSStyleDeclaration interface.
+ * @returns true if the style is selectable, otherwise false.
+ */
+ static isStyleSelectable(style) {
+ return !(
+ style.userSelect === 'none' ||
+ style.webkitUserSelect === 'none' ||
+ style.MozUserSelect === 'none' ||
+ style.msUserSelect === 'none'
+ );
+ }
+
+ /**
+ * Checks whether a CSS color is transparent or not.
+ * @param cssColor A CSS color string, expected to be encoded in rgb(a) form.
+ * @returns true if the color is transparent, otherwise false.
+ */
+ static isCSSColorTransparent(cssColor) {
+ return (
+ typeof cssColor === 'string' &&
+ cssColor.startsWith('rgba(') &&
+ /,\s*0.?0*\)$/.test(cssColor)
+ );
+ }
+
+ /**
+ * Checks whether a CSS display value will cause a layout change for text.
+ * @param cssDisplay A CSS string corresponding to the value of the display property.
+ * @returns true if the layout is changed by this value, otherwise false.
+ */
+ static doesCSSDisplayChangeLayout(cssDisplay) {
+ let pos = cssDisplay.indexOf(' ');
+ if (pos >= 0) {
+ // Truncate to <display-outside> part
+ cssDisplay = cssDisplay.substring(0, pos);
+ }
+
+ pos = cssDisplay.indexOf('-');
+ if (pos >= 0) {
+ // Truncate to first part of kebab-case value
+ cssDisplay = cssDisplay.substring(0, pos);
+ }
+
+ switch (cssDisplay) {
+ case 'block':
+ case 'flex':
+ case 'grid':
+ case 'list': // list-item
+ case 'table': // table, table-*
+ return true;
+ case 'ruby': // rubt-*
+ return (pos >= 0);
+ default:
+ return false;
+ }
+ }
+}
diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js
new file mode 100644
index 00000000..45186636
--- /dev/null
+++ b/ext/js/dom/text-source-element.js
@@ -0,0 +1,139 @@
+/*
+ * 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/>.
+ */
+
+class TextSourceElement {
+ constructor(element, fullContent=null, startOffset=0, endOffset=0) {
+ this._element = element;
+ this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element));
+ this._startOffset = startOffset;
+ this._endOffset = endOffset;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ }
+
+ get element() {
+ return this._element;
+ }
+
+ get fullContent() {
+ return this._fullContent;
+ }
+
+ get startOffset() {
+ return this._startOffset;
+ }
+
+ get endOffset() {
+ return this._endOffset;
+ }
+
+ get isConnected() {
+ return this._element.isConnected;
+ }
+
+ clone() {
+ return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset);
+ }
+
+ cleanup() {
+ // NOP
+ }
+
+ text() {
+ return this._content;
+ }
+
+ setEndOffset(length, fromEnd=false) {
+ if (fromEnd) {
+ const delta = Math.min(this._fullContent.length - this._endOffset, length);
+ this._endOffset += delta;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ return delta;
+ } else {
+ const delta = Math.min(this._fullContent.length - this._startOffset, length);
+ this._endOffset = this._startOffset + delta;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ return delta;
+ }
+ }
+
+ setStartOffset(length) {
+ const delta = Math.min(this._startOffset, length);
+ this._startOffset -= delta;
+ this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+ return delta;
+ }
+
+ collapse(toStart) {
+ if (toStart) {
+ this._endOffset = this._startOffset;
+ } else {
+ this._startOffset = this._endOffset;
+ }
+ this._content = '';
+ }
+
+ getRect() {
+ return this._element.getBoundingClientRect();
+ }
+
+ getWritingMode() {
+ return 'horizontal-tb';
+ }
+
+ select() {
+ // NOP
+ }
+
+ deselect() {
+ // NOP
+ }
+
+ hasSameStart(other) {
+ return (
+ typeof other === 'object' &&
+ other !== null &&
+ other instanceof TextSourceElement &&
+ this._element === other.element &&
+ this._fullContent === other.fullContent &&
+ this._startOffset === other.startOffset
+ );
+ }
+
+ getNodesInRange() {
+ return [this._element];
+ }
+
+ static getElementContent(element) {
+ let content;
+ switch (element.nodeName.toUpperCase()) {
+ case 'BUTTON':
+ content = element.textContent;
+ break;
+ case 'IMG':
+ content = element.getAttribute('alt') || '';
+ break;
+ default:
+ content = `${element.value}`;
+ break;
+ }
+
+ // Remove zero-width non-joiner
+ content = content.replace(/\u200c/g, '');
+
+ return content;
+ }
+}
diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js
new file mode 100644
index 00000000..377016da
--- /dev/null
+++ b/ext/js/dom/text-source-range.js
@@ -0,0 +1,170 @@
+/*
+ * 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
+ * DOMTextScanner
+ * DocumentUtil
+ */
+
+class TextSourceRange {
+ constructor(range, content, imposterContainer, imposterSourceElement) {
+ this._range = range;
+ this._rangeStartOffset = range.startOffset;
+ this._content = content;
+ this._imposterContainer = imposterContainer;
+ this._imposterSourceElement = imposterSourceElement;
+ }
+
+ get range() {
+ return this._range;
+ }
+
+ get rangeStartOffset() {
+ return this._rangeStartOffset;
+ }
+
+ get imposterSourceElement() {
+ return this._imposterSourceElement;
+ }
+
+ get isConnected() {
+ return (
+ this._range.startContainer.isConnected &&
+ this._range.endContainer.isConnected
+ );
+ }
+
+ clone() {
+ return new TextSourceRange(this._range.cloneRange(), this._content, this._imposterContainer, this._imposterSourceElement);
+ }
+
+ cleanup() {
+ if (this._imposterContainer !== null && this._imposterContainer.parentNode !== null) {
+ this._imposterContainer.parentNode.removeChild(this._imposterContainer);
+ }
+ }
+
+ text() {
+ return this._content;
+ }
+
+ setEndOffset(length, layoutAwareScan, fromEnd=false) {
+ const state = (
+ fromEnd ?
+ new DOMTextScanner(this._range.endContainer, this._range.endOffset, !layoutAwareScan, layoutAwareScan).seek(length) :
+ new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(length)
+ );
+ this._range.setEnd(state.node, state.offset);
+ this._content = (fromEnd ? this._content + state.content : state.content);
+ return length - state.remainder;
+ }
+
+ setStartOffset(length, layoutAwareScan) {
+ const state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(-length);
+ this._range.setStart(state.node, state.offset);
+ this._rangeStartOffset = this._range.startOffset;
+ this._content = state.content + this._content;
+ return length - state.remainder;
+ }
+
+ collapse(toStart) {
+ this._range.collapse(toStart);
+ this._content = '';
+ }
+
+ getRect() {
+ return this._range.getBoundingClientRect();
+ }
+
+ getWritingMode() {
+ return TextSourceRange.getElementWritingMode(TextSourceRange.getParentElement(this._range.startContainer));
+ }
+
+ select() {
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(this._range);
+ }
+
+ deselect() {
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ }
+
+ hasSameStart(other) {
+ if (!(
+ typeof other === 'object' &&
+ other !== null &&
+ other instanceof TextSourceRange
+ )) {
+ return false;
+ }
+ if (this._imposterSourceElement !== null) {
+ return (
+ this._imposterSourceElement === other.imposterSourceElement &&
+ this._rangeStartOffset === other.rangeStartOffset
+ );
+ } else {
+ try {
+ return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
+ } catch (e) {
+ if (e.name === 'WrongDocumentError') {
+ // This can happen with shadow DOMs if the ranges are in different documents.
+ return false;
+ }
+ throw e;
+ }
+ }
+ }
+
+ getNodesInRange() {
+ return DocumentUtil.getNodesInRange(this._range);
+ }
+
+ static getParentElement(node) {
+ while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
+ node = node.parentNode;
+ }
+ return node;
+ }
+
+ static getElementWritingMode(element) {
+ if (element !== null) {
+ const style = window.getComputedStyle(element);
+ const writingMode = style.writingMode;
+ if (typeof writingMode === 'string') {
+ return TextSourceRange.normalizeWritingMode(writingMode);
+ }
+ }
+ return 'horizontal-tb';
+ }
+
+ static normalizeWritingMode(writingMode) {
+ switch (writingMode) {
+ case 'lr':
+ case 'lr-tb':
+ case 'rl':
+ return 'horizontal-tb';
+ case 'tb':
+ return 'vertical-lr';
+ case 'tb-rl':
+ return 'vertical-rl';
+ default:
+ return writingMode;
+ }
+ }
+}