From b687870a55eae43a71ea3adc41be0ab341a8721f Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Tue, 8 Sep 2020 19:40:15 -0400
Subject: Delay hide option (#774)

* Add hideDelay option

* Add _clearSelection

* Use hideDelay

* Prevent repeated delayed selection clears

* Fix popup hide timer being cleared when the cursor is moved into the frame
---
 ext/bg/data/options-schema.json |  6 ++++
 ext/bg/js/options.js            |  2 ++
 ext/bg/settings.html            | 12 ++++++--
 ext/fg/js/frontend.js           | 64 +++++++++++++++++++++++++++++++++++------
 ext/fg/js/popup.js              | 10 +++++++
 ext/mixed/js/text-scanner.js    |  4 +++
 6 files changed, 87 insertions(+), 11 deletions(-)

diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json
index e2dd0573..52d96db9 100644
--- a/ext/bg/data/options-schema.json
+++ b/ext/bg/data/options-schema.json
@@ -324,6 +324,7 @@
                                     "alphanumeric",
                                     "autoHideResults",
                                     "delay",
+                                    "hideDelay",
                                     "length",
                                     "modifier",
                                     "deepDomScan",
@@ -360,6 +361,11 @@
                                         "minimum": 0,
                                         "default": 20
                                     },
+                                    "hideDelay": {
+                                        "type": "number",
+                                        "minimum": 0,
+                                        "default": 0
+                                    },
                                     "length": {
                                         "type": "integer",
                                         "minimum": 1,
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index 9dc0c166..398fb95c 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -473,6 +473,7 @@ class OptionsUtil {
         //  Options conditions converted to string representations.
         //  Added usePopupWindow.
         //  Updated handlebars templates to include "clipboard-image" definition.
+        //  Added hideDelay.
         for (const {conditionGroups} of options.profiles) {
             for (const {conditions} of conditionGroups) {
                 for (const condition of conditions) {
@@ -487,6 +488,7 @@ class OptionsUtil {
         }
         for (const {options: profileOptions} of options.profiles) {
             profileOptions.general.usePopupWindow = false;
+            profileOptions.scanning.hideDelay = 0;
         }
         await this._addFieldTemplatesToOptions(options, '/bg/data/anki-field-templates-upgrade-v4.handlebars');
         return options;
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 0ad5b79b..3fa14f49 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -415,8 +415,16 @@
                 </div>
 
                 <div class="form-group options-advanced">
-                    <label for="scan-delay">Scan delay <span class="label-light">(in milliseconds)</span></label>
-                    <input type="number" min="0" id="scan-delay" class="form-control" data-setting="scanning.delay">
+                    <div class="row">
+                        <div class="col-xs-6">
+                            <label for="scan-delay">Scan delay <span class="label-light">(in milliseconds)</span></label>
+                            <input type="number" min="0" id="scan-delay" class="form-control" data-setting="scanning.delay">
+                        </div>
+                        <div class="col-xs-6">
+                            <label for="scan-close-delay">Auto-hide delay <span class="label-light">(in milliseconds)</span></label>
+                            <input type="number" min="0" id="scan-close-delay" class="form-control" data-setting="scanning.hideDelay">
+                        </div>
+                    </div>
                 </div>
 
                 <div class="form-group options-advanced">
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
index 9177f985..e92feaf9 100644
--- a/ext/fg/js/frontend.js
+++ b/ext/fg/js/frontend.js
@@ -61,7 +61,10 @@ class Frontend {
         this._popupFactory = popupFactory;
         this._allowRootFramePopupProxy = allowRootFramePopupProxy;
         this._popupCache = new Map();
+        this._popupEventListeners = new EventListenerCollection();
         this._updatePopupToken = null;
+        this._clearSelectionTimer = null;
+        this._isPointerOverPopup = false;
 
         this._runtimeMessageHandlers = new Map([
             ['requestFrontendReadyBroadcast',        {async: false, handler: this._onMessageRequestFrontendReadyBroadcast.bind(this)}]
@@ -175,7 +178,7 @@ class Frontend {
     }
 
     _onApiClosePopup() {
-        this._textScanner.clearSelection(false);
+        this._clearSelection(false);
     }
 
     _onApiCopySelection() {
@@ -232,9 +235,11 @@ class Frontend {
     }
 
     _onClearSelection({passive}) {
+        this._stopClearSelectionDelayed();
         if (this._popup !== null) {
             this._popup.hide(!passive);
             this._popup.clearAutoPlayTimer();
+            this._isPointerOverPopup = false;
         }
         this._updatePendingOptions();
     }
@@ -249,24 +254,61 @@ class Frontend {
         await this.updateOptions();
     }
 
-    _onSearched({textScanner, type, definitions, sentence, input: {cause}, textSource, optionsContext, error}) {
+    _onSearched({type, definitions, sentence, input: {cause}, textSource, optionsContext, error}) {
+        const scanningOptions = this._options.scanning;
+
         if (error !== null) {
             if (yomichan.isExtensionUnloaded) {
-                if (textSource !== null && this._options.scanning.modifier !== 'none') {
+                if (textSource !== null && scanningOptions.modifier !== 'none') {
                     this._showExtensionUnloaded(textSource);
                 }
             } else {
                 yomichan.logError(error);
             }
+        } if (type !== null) {
+            this._stopClearSelectionDelayed();
+            const focus = (cause === 'mouse');
+            this._showContent(textSource, focus, definitions, type, sentence, optionsContext);
         } else {
-            if (type !== null) {
-                const focus = (cause === 'mouse');
-                this._showContent(textSource, focus, definitions, type, sentence, optionsContext);
+            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);
+        }
+    }
 
-        if (type === null && this._options.scanning.autoHideResults) {
-            textScanner.clearSelection(false);
+    _stopClearSelectionDelayed() {
+        if (this._clearSelectionTimer !== null) {
+            clearTimeout(this._clearSelectionTimer);
+            this._clearSelectionTimer = null;
         }
     }
 
@@ -354,8 +396,12 @@ class Frontend {
             this.setDisabledOverride(!this._options.scanning.enableOnSearchPage);
         }
 
-        this._textScanner.clearSelection(true);
+        this._clearSelection(true);
+        this._popupEventListeners.removeAllEventListeners();
         this._popup = popup;
+        this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this));
+        this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this));
+        this._isPointerOverPopup = false;
     }
 
     async _getDefaultPopup() {
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
index f644ee98..ee3bf646 100644
--- a/ext/fg/js/popup.js
+++ b/ext/fg/js/popup.js
@@ -95,6 +95,8 @@ class Popup extends EventDispatcher {
     // 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));
@@ -207,6 +209,14 @@ class Popup extends EventDispatcher {
 
     // Private functions
 
+    _onFrameMouseOver() {
+        this.trigger('framePointerOver', {});
+    }
+
+    _onFrameMouseOut() {
+        this.trigger('framePointerOut', {});
+    }
+
     _inject() {
         let injectPromise = this._injectPromise;
         if (injectPromise === null) {
diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js
index 2410f2b7..f3e99577 100644
--- a/ext/mixed/js/text-scanner.js
+++ b/ext/mixed/js/text-scanner.js
@@ -144,6 +144,10 @@ class TextScanner extends EventDispatcher {
         return clonedTextSource.text();
     }
 
+    hasSelection() {
+        return (this._textSourceCurrent !== null);
+    }
+
     clearSelection(passive) {
         if (!this._canClearSelection) { return; }
         if (this._textSourceCurrent !== null) {
-- 
cgit v1.2.3