diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index 6ba8467e..e21dc371 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -33,6 +33,7 @@ async function searchFrontendSetup() { window.frontendInitializationData = {depth: 1, ignoreNodes, proxy: false}; const scriptSrcs = [ + '/mixed/js/text-scanner.js', '/fg/js/frontend-api-receiver.js', '/fg/js/popup.js', '/fg/js/popup-proxy-host.js', diff --git a/ext/bg/search.html b/ext/bg/search.html index 58bb9ba8..7b4616da 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -74,6 +74,7 @@ + diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 9a1d507b..43a4830f 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -20,23 +20,14 @@ class Frontend { constructor(popup, ignoreNodes) { this.popup = popup; - this.popupTimerPromise = null; - this.textSourceCurrent = null; - this.pendingLookup = false; + this.textScanner = new TextScanner(window, ignoreNodes, this.popup, this.searchSource.bind(this)); this.options = null; - this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null); this.optionsContext = { depth: popup.depth, url: popup.url }; - this.primaryTouchIdentifier = null; - this.preventNextContextMenu = false; - this.preventNextMouseDown = false; - this.preventNextClick = false; - this.preventScroll = false; - this.enabled = false; this.eventListeners = []; @@ -71,162 +62,9 @@ class Frontend { return this.isPreparedPromise; } - onMouseOver(e) { - if (e.target === this.popup.container) { - this.popupTimerClear(); - } - } - - onMouseMove(e) { - this.popupTimerClear(); - - if (this.pendingLookup || DOM.isMouseButtonDown(e, 'primary')) { - return; - } - - const scanningOptions = this.options.scanning; - const scanningModifier = scanningOptions.modifier; - if (!( - Frontend.isScanningModifierPressed(scanningModifier, e) || - (scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary')) - )) { - return; - } - - const search = async () => { - if (scanningModifier === 'none') { - if (!await this.popupTimerWait()) { - // Aborted - return; - } - } - - await this.searchAt(e.clientX, e.clientY, 'mouse'); - }; - - search(); - } - - onMouseDown(e) { - if (this.preventNextMouseDown) { - this.preventNextMouseDown = false; - this.preventNextClick = true; - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (e.button === 0) { - this.popupTimerClear(); - this.searchClear(true); - } - } - - onMouseOut() { - this.popupTimerClear(); - } - - onClick(e) { - if (this.preventNextClick) { - this.preventNextClick = false; - e.preventDefault(); - e.stopPropagation(); - return false; - } - } - - onAuxClick() { - this.preventNextContextMenu = false; - } - - onContextMenu(e) { - if (this.preventNextContextMenu) { - this.preventNextContextMenu = false; - e.preventDefault(); - e.stopPropagation(); - return false; - } - } - - onTouchStart(e) { - if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) { - return; - } - - this.preventScroll = false; - this.preventNextContextMenu = false; - this.preventNextMouseDown = false; - this.preventNextClick = false; - - const primaryTouch = e.changedTouches[0]; - if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, window.getSelection())) { - return; - } - - this.primaryTouchIdentifier = primaryTouch.identifier; - - if (this.pendingLookup) { - return; - } - - const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null; - - this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart') - .then(() => { - if ( - this.textSourceCurrent === null || - this.textSourceCurrent.equals(textSourceCurrentPrevious) - ) { - return; - } - - this.preventScroll = true; - this.preventNextContextMenu = true; - this.preventNextMouseDown = true; - }); - } - - onTouchEnd(e) { - if ( - this.primaryTouchIdentifier === null || - this.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0 - ) { - return; - } - - this.primaryTouchIdentifier = null; - this.preventScroll = false; - this.preventNextClick = false; - // Don't revert context menu and mouse down prevention, - // since these events can occur after the touch has ended. - // this.preventNextContextMenu = false; - // this.preventNextMouseDown = false; - } - - onTouchCancel(e) { - this.onTouchEnd(e); - } - - onTouchMove(e) { - if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) { - return; - } - - const touches = e.changedTouches; - const index = this.getIndexOfTouch(touches, this.primaryTouchIdentifier); - if (index < 0) { - return; - } - - const primaryTouch = touches[index]; - this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove'); - - e.preventDefault(); // Disable scroll - } - async onResize() { - if (this.textSourceCurrent !== null && await this.popup.isVisibleAsync()) { - const textSource = this.textSourceCurrent; + const textSource = this.textScanner.getCurrentTextSource(); + if (textSource !== null && await this.popup.isVisibleAsync()) { this.lastShowPromise = this.popup.showContent( textSource.getRect(), textSource.getWritingMode() @@ -257,6 +95,7 @@ class Frontend { } setEnabled(enabled) { + this.textScanner.setEnabled(enabled); if (enabled) { if (!this.enabled) { this.hookEvents(); @@ -273,21 +112,7 @@ class Frontend { hookEvents() { this.addEventListener(window, 'message', this.onWindowMessage.bind(this)); - this.addEventListener(window, 'mousedown', this.onMouseDown.bind(this)); - this.addEventListener(window, 'mousemove', this.onMouseMove.bind(this)); - this.addEventListener(window, 'mouseover', this.onMouseOver.bind(this)); - this.addEventListener(window, 'mouseout', this.onMouseOut.bind(this)); this.addEventListener(window, 'resize', this.onResize.bind(this)); - - if (this.options.scanning.touchInputEnabled) { - this.addEventListener(window, 'click', this.onClick.bind(this)); - this.addEventListener(window, 'auxclick', this.onAuxClick.bind(this)); - this.addEventListener(window, 'touchstart', this.onTouchStart.bind(this)); - this.addEventListener(window, 'touchend', this.onTouchEnd.bind(this)); - this.addEventListener(window, 'touchcancel', this.onTouchCancel.bind(this)); - this.addEventListener(window, 'touchmove', this.onTouchMove.bind(this), {passive: false}); - this.addEventListener(window, 'contextmenu', this.onContextMenu.bind(this)); - } } addEventListener(node, type, listener, options) { @@ -304,60 +129,16 @@ class Frontend { async updateOptions() { this.options = await apiOptionsGet(this.getOptionsContext()); - this.setEnabled(this.options.general.enable); + this.textScanner.setOptions(this.options); await this.popup.setOptions(this.options); - } - - async popupTimerWait() { - const delay = this.options.scanning.delay; - const promise = promiseTimeout(delay, true); - this.popupTimerPromise = promise; - try { - return await promise; - } finally { - if (this.popupTimerPromise === promise) { - this.popupTimerPromise = null; - } - } - } - - popupTimerClear() { - if (this.popupTimerPromise !== null) { - this.popupTimerPromise.resolve(false); - this.popupTimerPromise = null; - } - } - - async searchAt(x, y, cause) { - try { - this.popupTimerClear(); - - if (this.pendingLookup || await this.popup.containsPoint(x, y)) { - return; - } - - const textSource = docRangeFromPoint(x, y, this.options); - if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) { - return; - } - - try { - await this.searchSource(textSource, cause); - } finally { - if (textSource !== null) { - textSource.cleanup(); - } - } - } catch (e) { - this.onError(e); - } + this.setEnabled(this.options.general.enable); } async searchSource(textSource, cause) { let results = null; try { - this.pendingLookup = true; + this.textScanner.pendingLookup = true; if (textSource !== null) { results = ( await this.findTerms(textSource) || @@ -385,7 +166,7 @@ class Frontend { this.searchClear(true); } - this.pendingLookup = false; + this.textScanner.pendingLookup = false; } return results; @@ -401,14 +182,14 @@ class Frontend { {definitions, context: {sentence, url, focus, disableHistory: true}} ); - this.textSourceCurrent = textSource; + this.textScanner.setCurrentTextSource(textSource); if (this.options.scanning.selectText) { textSource.select(); } } async findTerms(textSource) { - this.setTextSourceScanLength(textSource, this.options.scanning.length); + this.textScanner.setTextSourceScanLength(textSource, this.options.scanning.length); const searchText = textSource.text(); if (searchText.length === 0) { return null; } @@ -422,7 +203,7 @@ class Frontend { } async findKanji(textSource) { - this.setTextSourceScanLength(textSource, 1); + this.textScanner.setTextSourceScanLength(textSource, 1); const searchText = textSource.text(); if (searchText.length === 0) { return null; } @@ -436,57 +217,13 @@ class Frontend { searchClear(changeFocus) { this.popup.hide(changeFocus); this.popup.clearAutoPlayTimer(); - - if (this.textSourceCurrent !== null) { - if (this.options.scanning.selectText) { - this.textSourceCurrent.deselect(); - } - - this.textSourceCurrent = null; - } - } - - getIndexOfTouch(touchList, identifier) { - for (const i in touchList) { - const t = touchList[i]; - if (t.identifier === identifier) { - return i; - } - } - return -1; - } - - setTextSourceScanLength(textSource, length) { - textSource.setEndOffset(length); - if (this.ignoreNodes === null || !textSource.range) { - return; - } - - length = textSource.text().length; - while (textSource.range && length > 0) { - const nodes = TextSourceRange.getNodesInRange(textSource.range); - if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) { - break; - } - --length; - textSource.setEndOffset(length); - } + this.textScanner.searchClear(); } getOptionsContext() { this.optionsContext.url = this.popup.url; return this.optionsContext; } - - static isScanningModifierPressed(scanningModifier, mouseEvent) { - switch (scanningModifier) { - case 'alt': return mouseEvent.altKey; - case 'ctrl': return mouseEvent.ctrlKey; - case 'shift': return mouseEvent.shiftKey; - case 'none': return true; - default: return false; - } - } } Frontend.windowMessageHandlers = { diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js index 31cb1cda..3df469fe 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/popup-nested.js @@ -41,6 +41,7 @@ async function popupNestedInitialize(id, depth, parentFrameId, url) { window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes, url, proxy: true}; const scriptSrcs = [ + '/mixed/js/text-scanner.js', '/fg/js/frontend-api-sender.js', '/fg/js/popup.js', '/fg/js/popup-proxy.js', diff --git a/ext/manifest.json b/ext/manifest.json index 9a9d6639..f91a6b33 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -20,6 +20,7 @@ "js": [ "mixed/js/core.js", "mixed/js/dom.js", + "mixed/js/text-scanner.js", "fg/js/api.js", "fg/js/document.js", "fg/js/frontend-api-receiver.js", diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js new file mode 100644 index 00000000..cf6e5397 --- /dev/null +++ b/ext/mixed/js/text-scanner.js @@ -0,0 +1,341 @@ +/* + * Copyright (C) 2019 Alex Yatskov + * Author: Alex Yatskov + * + * 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 . + */ + + +class TextScanner { + constructor(node, ignoreNodes, popup, onTextSearch) { + this.node = node; + this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null); + this.popup = popup; + this.onTextSearch = onTextSearch; + + this.popupTimerPromise = null; + this.textSourceCurrent = null; + this.pendingLookup = false; + this.options = null; + + this.enabled = false; + this.eventListeners = []; + + this.primaryTouchIdentifier = null; + this.preventNextContextMenu = false; + this.preventNextMouseDown = false; + this.preventNextClick = false; + this.preventScroll = false; + } + + onMouseOver(e) { + if (this.popup && e.target === this.popup.container) { + this.popupTimerClear(); + } + } + + onMouseMove(e) { + this.popupTimerClear(); + + if (this.pendingLookup || DOM.isMouseButtonDown(e, 'primary')) { + return; + } + + const scanningOptions = this.options.scanning; + const scanningModifier = scanningOptions.modifier; + if (!( + TextScanner.isScanningModifierPressed(scanningModifier, e) || + (scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary')) + )) { + return; + } + + const search = async () => { + if (scanningModifier === 'none') { + if (!await this.popupTimerWait()) { + // Aborted + return; + } + } + + await this.searchAt(e.clientX, e.clientY, 'mouse'); + }; + + search(); + } + + onMouseDown(e) { + if (this.preventNextMouseDown) { + this.preventNextMouseDown = false; + this.preventNextClick = true; + e.preventDefault(); + e.stopPropagation(); + return false; + } + + if (DOM.isMouseButtonPressed(e, 'primary')) { + this.popupTimerClear(); + this.searchClear(); + } + } + + onMouseOut() { + this.popupTimerClear(); + } + + onClick(e) { + if (this.preventNextClick) { + this.preventNextClick = false; + e.preventDefault(); + e.stopPropagation(); + return false; + } + } + + onAuxClick() { + this.preventNextContextMenu = false; + } + + onContextMenu(e) { + if (this.preventNextContextMenu) { + this.preventNextContextMenu = false; + e.preventDefault(); + e.stopPropagation(); + return false; + } + } + + onTouchStart(e) { + if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) { + return; + } + + this.preventScroll = false; + this.preventNextContextMenu = false; + this.preventNextMouseDown = false; + this.preventNextClick = false; + + const primaryTouch = e.changedTouches[0]; + if (DOM.isPointInSelection(primaryTouch.clientX, primaryTouch.clientY, this.node.getSelection())) { + return; + } + + this.primaryTouchIdentifier = primaryTouch.identifier; + + if (this.pendingLookup) { + return; + } + + const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null; + + this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart') + .then(() => { + if ( + this.textSourceCurrent === null || + this.textSourceCurrent.equals(textSourceCurrentPrevious) + ) { + return; + } + + this.preventScroll = true; + this.preventNextContextMenu = true; + this.preventNextMouseDown = true; + }); + } + + onTouchEnd(e) { + if ( + this.primaryTouchIdentifier === null || + TextScanner.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0 + ) { + return; + } + + this.primaryTouchIdentifier = null; + this.preventScroll = false; + this.preventNextClick = false; + // Don't revert context menu and mouse down prevention, + // since these events can occur after the touch has ended. + // this.preventNextContextMenu = false; + // this.preventNextMouseDown = false; + } + + onTouchCancel(e) { + this.onTouchEnd(e); + } + + onTouchMove(e) { + if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) { + return; + } + + const touches = e.changedTouches; + const index = TextScanner.getIndexOfTouch(touches, this.primaryTouchIdentifier); + if (index < 0) { + return; + } + + const primaryTouch = touches[index]; + this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove'); + + e.preventDefault(); // Disable scroll + } + + async popupTimerWait() { + const delay = this.options.scanning.delay; + const promise = promiseTimeout(delay, true); + this.popupTimerPromise = promise; + try { + return await promise; + } finally { + if (this.popupTimerPromise === promise) { + this.popupTimerPromise = null; + } + } + } + + popupTimerClear() { + if (this.popupTimerPromise !== null) { + this.popupTimerPromise.resolve(false); + this.popupTimerPromise = null; + } + } + + setEnabled(enabled) { + if (enabled) { + if (!this.enabled) { + this.hookEvents(); + this.enabled = true; + } + } else { + if (this.enabled) { + this.clearEventListeners(); + this.enabled = false; + } + this.searchClear(); + } + } + + hookEvents() { + this.addEventListener('mousedown', this.onMouseDown.bind(this)); + this.addEventListener('mousemove', this.onMouseMove.bind(this)); + this.addEventListener('mouseover', this.onMouseOver.bind(this)); + this.addEventListener('mouseout', this.onMouseOut.bind(this)); + + if (this.options.scanning.touchInputEnabled) { + this.addEventListener('click', this.onClick.bind(this)); + this.addEventListener('auxclick', this.onAuxClick.bind(this)); + this.addEventListener('touchstart', this.onTouchStart.bind(this)); + this.addEventListener('touchend', this.onTouchEnd.bind(this)); + this.addEventListener('touchcancel', this.onTouchCancel.bind(this)); + this.addEventListener('touchmove', this.onTouchMove.bind(this), {passive: false}); + this.addEventListener('contextmenu', this.onContextMenu.bind(this)); + } + } + + addEventListener(type, listener, options) { + this.node.addEventListener(type, listener, options); + this.eventListeners.push([type, listener, options]); + } + + clearEventListeners() { + for (const [type, listener, options] of this.eventListeners) { + this.node.removeEventListener(type, listener, options); + } + this.eventListeners = []; + } + + setOptions(options) { + this.options = options; + } + + async searchAt(x, y, cause) { + try { + this.popupTimerClear(); + + if (this.pendingLookup || (this.popup && await this.popup.containsPoint(x, y))) { + return; + } + + const textSource = docRangeFromPoint(x, y, this.options); + if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) { + return; + } + + try { + await this.onTextSearch(textSource, cause); + } finally { + if (textSource !== null) { + textSource.cleanup(); + } + } + } catch (e) { + this.onError(e); + } + } + + setTextSourceScanLength(textSource, length) { + textSource.setEndOffset(length); + if (this.ignoreNodes === null || !textSource.range) { + return; + } + + length = textSource.text().length; + while (textSource.range && length > 0) { + const nodes = TextSourceRange.getNodesInRange(textSource.range); + if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) { + break; + } + --length; + textSource.setEndOffset(length); + } + } + + searchClear() { + if (this.textSourceCurrent !== null) { + if (this.options.scanning.selectText) { + this.textSourceCurrent.deselect(); + } + this.textSourceCurrent = null; + } + } + + getCurrentTextSource() { + return this.textSourceCurrent; + } + + setCurrentTextSource(textSource) { + return this.textSourceCurrent = textSource; + } + + static isScanningModifierPressed(scanningModifier, mouseEvent) { + switch (scanningModifier) { + case 'alt': return mouseEvent.altKey; + case 'ctrl': return mouseEvent.ctrlKey; + case 'shift': return mouseEvent.shiftKey; + case 'none': return true; + default: return false; + } + } + + static getIndexOfTouch(touchList, identifier) { + for (const i in touchList) { + const t = touchList[i]; + if (t.identifier === identifier) { + return i; + } + } + return -1; + } +}