/* * Copyright (C) 2023 Yomitan Authors * Copyright (C) 2016-2022 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/>. */ import {EventListenerCollection, log, promiseAnimationFrame} from '../core.js'; import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; import {DocumentUtil} from '../dom/document-util.js'; import {TextSourceElement} from '../dom/text-source-element.js'; import {TextSourceRange} from '../dom/text-source-range.js'; import {TextScanner} from '../language/text-scanner.js'; import {yomitan} from '../yomitan.js'; /** * This is the main class responsible for scanning and handling webpage content. */ export class Frontend { /** * Creates a new instance. * @param {import('frontend').ConstructorDetails} details Details about how to set up the instance. */ constructor({ pageType, popupFactory, depth, tabId, frameId, parentPopupId, parentFrameId, useProxyPopup, canUseWindowPopup = true, allowRootFramePopupProxy, childrenSupported = true, hotkeyHandler }) { /** @type {import('frontend').PageType} */ this._pageType = pageType; /** @type {import('./popup-factory.js').PopupFactory} */ this._popupFactory = popupFactory; /** @type {number} */ this._depth = depth; /** @type {number|undefined} */ this._tabId = tabId; /** @type {number} */ this._frameId = frameId; /** @type {?string} */ this._parentPopupId = parentPopupId; /** @type {?number} */ this._parentFrameId = parentFrameId; /** @type {boolean} */ this._useProxyPopup = useProxyPopup; /** @type {boolean} */ this._canUseWindowPopup = canUseWindowPopup; /** @type {boolean} */ this._allowRootFramePopupProxy = allowRootFramePopupProxy; /** @type {boolean} */ this._childrenSupported = childrenSupported; /** @type {import('../input/hotkey-handler.js').HotkeyHandler} */ this._hotkeyHandler = hotkeyHandler; /** @type {?import('popup').PopupAny} */ this._popup = null; /** @type {boolean} */ this._disabledOverride = false; /** @type {?import('settings').ProfileOptions} */ this._options = null; /** @type {number} */ this._pageZoomFactor = 1.0; /** @type {number} */ this._contentScale = 1.0; /** @type {Promise<void>} */ this._lastShowPromise = Promise.resolve(); /** @type {TextScanner} */ this._textScanner = new TextScanner({ node: window, ignoreElements: this._ignoreElements.bind(this), ignorePoint: this._ignorePoint.bind(this), getSearchContext: this._getSearchContext.bind(this), searchTerms: true, searchKanji: true }); /** @type {boolean} */ this._textScannerHasBeenEnabled = false; /** @type {Map<'default'|'window'|'iframe'|'proxy', Promise<?import('popup').PopupAny>>} */ this._popupCache = new Map(); /** @type {EventListenerCollection} */ this._popupEventListeners = new EventListenerCollection(); /** @type {?import('core').TokenObject} */ this._updatePopupToken = null; /** @type {?import('core').Timeout} */ this._clearSelectionTimer = null; /** @type {boolean} */ this._isPointerOverPopup = false; /** @type {?import('settings').OptionsContext} */ this._optionsContextOverride = null; /* eslint-disable no-multi-spaces */ /** @type {import('application').ApiMap} */ this._runtimeApiMap = createApiMap([ ['frontendRequestReadyBroadcast', this._onMessageRequestFrontendReadyBroadcast.bind(this)], ['frontendSetAllVisibleOverride', this._onApiSetAllVisibleOverride.bind(this)], ['frontendClearAllVisibleOverride', this._onApiClearAllVisibleOverride.bind(this)] ]); this._hotkeyHandler.registerActions([ ['scanSelectedText', this._onActionScanSelectedText.bind(this)], ['scanTextAtCaret', this._onActionScanTextAtCaret.bind(this)] ]); /* eslint-enable no-multi-spaces */ } /** * Get whether or not the text selection can be cleared. * @type {boolean} */ get canClearSelection() { return this._textScanner.canClearSelection; } /** * Set whether or not the text selection can be cleared. * @param {boolean} value The new value to assign. */ set canClearSelection(value) { this._textScanner.canClearSelection = value; } /** * Gets the popup instance. * @type {?import('popup').PopupAny} */ get popup() { return this._popup; } /** * Prepares the instance for use. */ async prepare() { await this.updateOptions(); try { const {zoomFactor} = await yomitan.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; if (typeof visualViewport !== 'undefined' && visualViewport !== null) { visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this)); visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this)); } yomitan.on('optionsUpdated', this.updateOptions.bind(this)); yomitan.on('zoomChanged', this._onZoomChanged.bind(this)); yomitan.on('closePopups', this._onClosePopups.bind(this)); chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this)); this._textScanner.on('clear', this._onTextScannerClear.bind(this)); this._textScanner.on('searched', this._onSearched.bind(this)); /* eslint-disable no-multi-spaces */ yomitan.crossFrame.registerHandlers([ ['Frontend.closePopup', this._onApiClosePopup.bind(this)], ['Frontend.copySelection', this._onApiCopySelection.bind(this)], ['Frontend.getSelectionText', this._onApiGetSelectionText.bind(this)], ['Frontend.getPopupInfo', this._onApiGetPopupInfo.bind(this)], ['Frontend.getPageInfo', this._onApiGetPageInfo.bind(this)] ]); /* eslint-enable no-multi-spaces */ this._prepareSiteSpecific(); this._updateContentScale(); this._signalFrontendReady(null); } /** * Set whether or not the instance is disabled. * @param {boolean} disabled Whether or not the instance is disabled. */ setDisabledOverride(disabled) { this._disabledOverride = disabled; this._updateTextScannerEnabled(); } /** * Set or clear an override options context object. * @param {?import('settings').OptionsContext} optionsContext An options context object to use as the override, or `null` to clear the override. */ setOptionsContextOverride(optionsContext) { this._optionsContextOverride = optionsContext; } /** * Performs a new search on a specific source. * @param {import('text-source').TextSource} textSource The text source to search. */ async setTextSource(textSource) { this._textScanner.setCurrentTextSource(null); await this._textScanner.search(textSource); } /** * Updates the internal options representation. */ async updateOptions() { try { await this._updateOptionsInternal(); } catch (e) { if (!yomitan.isExtensionUnloaded) { throw e; } } } /** * Waits for the previous `showContent` call to be completed. * @returns {Promise<void>} A promise which is resolved when the previous `showContent` call has completed. */ showContentCompleted() { return this._lastShowPromise; } // Message handlers /** @type {import('application').ApiHandler<'frontendRequestReadyBroadcast'>} */ _onMessageRequestFrontendReadyBroadcast({frameId}) { this._signalFrontendReady(frameId); } // Action handlers /** * @returns {void} */ _onActionScanSelectedText() { this._scanSelectedText(false); } /** * @returns {void} */ _onActionScanTextAtCaret() { this._scanSelectedText(true); } // API message handlers /** * @returns {string} */ _onApiGetUrl() { return window.location.href; } /** * @returns {void} */ _onApiClosePopup() { this._clearSelection(false); } /** * @returns {void} */ _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'); } /** * @returns {string} */ _onApiGetSelectionText() { const selection = document.getSelection(); return selection !== null ? selection.toString() : ''; } /** * @returns {import('frontend').GetPopupInfoResult} */ _onApiGetPopupInfo() { return { popupId: (this._popup !== null ? this._popup.id : null) }; } /** * @returns {{url: string, documentTitle: string}} */ _onApiGetPageInfo() { return { url: window.location.href, documentTitle: document.title }; } /** @type {import('application').ApiHandler<'frontendSetAllVisibleOverride'>} */ async _onApiSetAllVisibleOverride({value, priority, awaitFrame}) { const result = await this._popupFactory.setAllVisibleOverride(value, priority); if (awaitFrame) { await promiseAnimationFrame(100); } return result; } /** @type {import('application').ApiHandler<'frontendClearAllVisibleOverride'>} */ async _onApiClearAllVisibleOverride({token}) { return await this._popupFactory.clearAllVisibleOverride(token); } // Private /** * @returns {void} */ _onResize() { this._updatePopupPosition(); } /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */ _onRuntimeMessage({action, params}, _sender, callback) { return invokeApiMapHandler(this._runtimeApiMap, action, params, [], callback); } /** * @param {{newZoomFactor: number}} params */ _onZoomChanged({newZoomFactor}) { this._pageZoomFactor = newZoomFactor; this._updateContentScale(); } /** * @returns {void} */ _onClosePopups() { this._clearSelection(true); } /** * @returns {void} */ _onVisualViewportScroll() { this._updatePopupPosition(); } /** * @returns {void} */ _onVisualViewportResize() { this._updateContentScale(); } /** * @returns {void} */ _onTextScannerClear() { this._clearSelection(false); } /** * @param {import('text-scanner').SearchedEventDetails} details */ _onSearched({type, dictionaryEntries, sentence, inputInfo: {eventType, passive, detail: inputInfoDetail}, textSource, optionsContext, detail, error}) { const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning; if (error !== null) { if (yomitan.isExtensionUnloaded) { if (textSource !== null && !passive) { this._showExtensionUnloaded(textSource); } } else { log.error(error); } } if (type !== null && optionsContext !== null) { this._stopClearSelectionDelayed(); let focus = (eventType === 'mouseMove'); if (typeof inputInfoDetail === 'object' && inputInfoDetail !== null) { const focus2 = inputInfoDetail.focus; if (typeof focus2 === 'boolean') { focus = focus2; } } this._showContent(textSource, focus, dictionaryEntries, type, sentence, detail !== null ? detail.documentTitle : null, optionsContext); } else { if (scanningOptions.autoHideResults) { this._clearSelectionDelayed(scanningOptions.hideDelay, false, false); } } } /** * @returns {void} */ _onPopupFramePointerOver() { this._isPointerOverPopup = true; this._stopClearSelectionDelayed(); } /** * @returns {void} */ _onPopupFramePointerOut() { this._isPointerOverPopup = false; const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning; if (scanningOptions.hidePopupOnCursorExit) { this._clearSelectionDelayed(scanningOptions.hidePopupOnCursorExitDelay, false, false); } } /** * @param {boolean} passive */ _clearSelection(passive) { this._stopClearSelectionDelayed(); if (this._popup !== null) { this._popup.clearAutoPlayTimer(); this._popup.hide(!passive); this._isPointerOverPopup = false; } this._textScanner.clearSelection(); } /** * @param {number} delay * @param {boolean} restart * @param {boolean} 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); } } /** * @returns {void} */ _stopClearSelectionDelayed() { if (this._clearSelectionTimer !== null) { clearTimeout(this._clearSelectionTimer); this._clearSelectionTimer = null; } } /** * @returns {Promise<void>} */ async _updateOptionsInternal() { const optionsContext = await this._getOptionsContext(); const options = await yomitan.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, normalizeCssZoom: scanningOptions.normalizeCssZoom, selectText: scanningOptions.selectText, delay: scanningOptions.delay, touchInputEnabled: scanningOptions.touchInputEnabled, pointerEventsEnabled: scanningOptions.pointerEventsEnabled, scanLength: scanningOptions.length, layoutAwareScan: scanningOptions.layoutAwareScan, matchTypePrefix: scanningOptions.matchTypePrefix, 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(); } /** * @returns {Promise<void>} */ async _updatePopup() { const {usePopupWindow, showIframePopupsInRootFrame} = /** @type {import('settings').ProfileOptions} */ (this._options).general; const isIframe = !this._useProxyPopup && (window !== window.parent); const currentPopup = this._popup; /** @type {Promise<?import('popup').PopupAny>|undefined} */ 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. /** @type {?import('core').TokenObject} */ 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; } /** * @returns {Promise<?import('popup').PopupAny>} */ 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 }); } /** * @returns {Promise<import('popup').PopupAny>} */ async _getProxyPopup() { return await this._popupFactory.getOrCreatePopup({ frameId: this._parentFrameId, depth: this._depth, parentPopupId: this._parentPopupId, childrenSupported: this._childrenSupported }); } /** * @returns {Promise<?import('popup').PopupAny>} */ async _getIframeProxyPopup() { const targetFrameId = 0; // Root frameId try { await this._waitForFrontendReady(targetFrameId, 10000); } catch (e) { // Root frame not available return await this._getDefaultPopup(); } /** @type {import('frontend').GetPopupInfoResult} */ const {popupId} = await yomitan.crossFrame.invoke(targetFrameId, 'Frontend.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; } /** * @returns {Promise<import('popup').PopupAny>} */ async _getPopupWindow() { return await this._popupFactory.getOrCreatePopup({ depth: this._depth, popupWindow: true, childrenSupported: this._childrenSupported }); } /** * @returns {Element[]} */ _ignoreElements() { if (this._popup !== null) { const container = this._popup.container; if (container !== null) { return [container]; } } return []; } /** * @param {number} x * @param {number} y * @returns {Promise<boolean>} */ async _ignorePoint(x, y) { try { return this._popup !== null && await this._popup.containsPoint(x, y); } catch (e) { if (!yomitan.isExtensionUnloaded) { throw e; } return false; } } /** * @param {import('text-source').TextSource} textSource */ _showExtensionUnloaded(textSource) { this._showPopupContent(textSource, null, null); } /** * @param {import('text-source').TextSource} textSource * @param {boolean} focus * @param {?import('dictionary').DictionaryEntry[]} dictionaryEntries * @param {import('display').PageType} type * @param {?import('display').HistoryStateSentence} sentence * @param {?string} documentTitle * @param {import('settings').OptionsContext} optionsContext */ _showContent(textSource, focus, dictionaryEntries, type, sentence, documentTitle, optionsContext) { const query = textSource.text(); const {url} = optionsContext; /** @type {import('display').HistoryState} */ const detailsState = { focusEntry: 0, optionsContext, url }; if (sentence !== null) { detailsState.sentence = sentence; } if (documentTitle !== null) { detailsState.documentTitle = documentTitle; } /** @type {import('display').HistoryContent} */ const detailsContent = { contentOrigin: { tabId: this._tabId, frameId: this._frameId } }; if (dictionaryEntries !== null) { detailsContent.dictionaryEntries = dictionaryEntries; } /** @type {import('display').ContentDetails} */ const details = { focus, historyMode: 'clear', params: { type, query, wildcards: 'off' }, state: detailsState, content: detailsContent }; if (textSource instanceof TextSourceElement && textSource.fullContent !== query) { details.params.full = textSource.fullContent; details.params['full-visible'] = 'true'; } this._showPopupContent(textSource, optionsContext, details); } /** * @param {import('text-source').TextSource} textSource * @param {?import('settings').OptionsContext} optionsContext * @param {?import('display').ContentDetails} details * @returns {Promise<void>} */ _showPopupContent(textSource, optionsContext, details) { const sourceRects = []; for (const {left, top, right, bottom} of textSource.getRects()) { sourceRects.push({left, top, right, bottom}); } this._lastShowPromise = ( this._popup !== null ? this._popup.showContent( { optionsContext, sourceRects, writingMode: textSource.getWritingMode() }, details ) : Promise.resolve() ); this._lastShowPromise.catch((error) => { if (yomitan.isExtensionUnloaded) { return; } log.error(error); }); return this._lastShowPromise; } /** * @returns {void} */ _updateTextScannerEnabled() { const enabled = (this._options !== null && this._options.general.enable && !this._disabledOverride); if (enabled === this._textScanner.isEnabled()) { return; } this._textScanner.setEnabled(enabled); if (this._textScannerHasBeenEnabled) { this._clearSelection(true); } if (enabled) { this._textScannerHasBeenEnabled = true; } } /** * @returns {void} */ _updateContentScale() { const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = /** @type {import('settings').ProfileOptions} */ (this._options).general; let contentScale = popupScalingFactor; if (popupScaleRelativeToPageZoom) { contentScale /= this._pageZoomFactor; } if (popupScaleRelativeToVisualViewport) { const {visualViewport} = window; const visualViewportScale = (typeof visualViewport !== 'undefined' && visualViewport !== null ? 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(); } /** * @returns {Promise<void>} */ async _updatePopupPosition() { const textSource = this._textScanner.getCurrentTextSource(); if ( textSource !== null && this._popup !== null && await this._popup.isVisible() ) { this._showPopupContent(textSource, null, null); } } /** * @param {?number} targetFrameId */ _signalFrontendReady(targetFrameId) { /** @type {import('application').ApiMessageNoFrameId<'frontendReady'>} */ const message = {action: 'frontendReady', params: {frameId: this._frameId}}; if (targetFrameId === null) { yomitan.api.broadcastTab(message); } else { yomitan.api.sendMessageToFrame(targetFrameId, message); } } /** * @param {number} frameId * @param {?number} timeout * @returns {Promise<void>} */ async _waitForFrontendReady(frameId, timeout) { return new Promise((resolve, reject) => { /** @type {?import('core').Timeout} */ let timeoutId = null; const cleanup = () => { if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; } chrome.runtime.onMessage.removeListener(onMessage); }; /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */ const onMessage = (message, _sender, sendResponse) => { try { const {action} = message; if (action === 'frontendReady' && message.params.frameId === frameId) { cleanup(); resolve(); sendResponse(); } } catch (e) { // NOP } }; if (timeout !== null) { timeoutId = setTimeout(() => { timeoutId = null; cleanup(); reject(new Error(`Wait for frontend ready timed out after ${timeout}ms`)); }, timeout); } chrome.runtime.onMessage.addListener(onMessage); yomitan.api.broadcastTab({action: 'frontendRequestReadyBroadcast', params: {frameId: this._frameId}}); }); } /** * @param {import('settings').PreventMiddleMouseOptions} preventMiddleMouseOptions * @returns {boolean} */ _getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) { switch (this._pageType) { case 'web': return preventMiddleMouseOptions.onWebPages; case 'popup': return preventMiddleMouseOptions.onPopupPages; case 'search': return preventMiddleMouseOptions.onSearchPages; default: return false; } } /** * @returns {Promise<import('settings').OptionsContext>} */ async _getOptionsContext() { let optionsContext = this._optionsContextOverride; if (optionsContext === null) { optionsContext = (await this._getSearchContext()).optionsContext; } return optionsContext; } /** * @returns {Promise<{optionsContext: import('settings').OptionsContext, detail?: import('text-scanner').SearchResultDetail}>} */ async _getSearchContext() { let url = window.location.href; let documentTitle = document.title; if (this._useProxyPopup && this._parentFrameId !== null) { try { ({url, documentTitle} = await yomitan.crossFrame.invoke(this._parentFrameId, 'Frontend.getPageInfo', {})); } catch (e) { // NOP } } let optionsContext = this._optionsContextOverride; if (optionsContext === null) { optionsContext = {depth: this._depth, url}; } return { optionsContext, detail: {documentTitle} }; } /** * @param {boolean} allowEmptyRange * @returns {Promise<boolean>} */ async _scanSelectedText(allowEmptyRange) { const range = this._getFirstSelectionRange(allowEmptyRange); if (range === null) { return false; } const source = TextSourceRange.create(range); await this._textScanner.search(source, {focus: true, restoreSelection: true}); return true; } /** * @param {boolean} allowEmptyRange * @returns {?Range} */ _getFirstSelectionRange(allowEmptyRange) { const selection = window.getSelection(); if (selection === null) { return null; } for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { const range = selection.getRangeAt(i); if (range.toString().length > 0 || allowEmptyRange) { return range; } } return null; } /** * @returns {void} */ _prepareSiteSpecific() { switch (location.hostname.toLowerCase()) { case 'docs.google.com': this._prepareGoogleDocs(); break; } } /** * @returns {Promise<void>} */ async _prepareGoogleDocs() { const {GoogleDocsUtil} = await import('../accessibility/google-docs-util.js'); DocumentUtil.registerGetRangeFromPointHandler(GoogleDocsUtil.getRangeFromPoint.bind(GoogleDocsUtil)); } }