diff options
Diffstat (limited to 'ext/js')
| -rw-r--r-- | ext/js/app/content-script-main.js | 59 | ||||
| -rw-r--r-- | ext/js/app/frontend.js | 691 | ||||
| -rw-r--r-- | ext/js/app/popup-factory.js | 319 | ||||
| -rw-r--r-- | ext/js/app/popup-proxy.js | 218 | ||||
| -rw-r--r-- | ext/js/app/popup-window.js | 169 | ||||
| -rw-r--r-- | ext/js/app/popup.js | 687 | ||||
| -rw-r--r-- | ext/js/comm/frame-ancestry-handler.js | 269 | ||||
| -rw-r--r-- | ext/js/comm/frame-offset-forwarder.js | 70 | ||||
| -rw-r--r-- | ext/js/display/display.js | 14 | ||||
| -rw-r--r-- | ext/js/display/popup-main.js | 56 | ||||
| -rw-r--r-- | ext/js/dom/dom-text-scanner.js | 551 | ||||
| -rw-r--r-- | ext/js/dom/text-source-element.js | 139 | ||||
| -rw-r--r-- | ext/js/dom/text-source-range.js | 170 | 
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; +        } +    } +} |