Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)

Fix "Hide popup on cursor exit" setting conflicting with "Allow scanning popup content" nested popups (fixed errors) (#2166)

* nested popup distinguished from screen, mousing back to a lower-hierarchy node clears all higher ones

* remove debugging logs

* Remove console logs

* Fix test errors

* Only hide child popups when the option is selected

---------

Co-authored-by: Jack Mitchell <jackmitc@usc.edu>

authored by

Aurora
Jack Mitchell
and committed by
GitHub
bb68a094 fed647bb

+132 -18
+58 -10
ext/js/app/frontend.js
··· 402 _onSearchEmpty() { 403 const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning; 404 if (scanningOptions.autoHideResults) { 405 - this._clearSelectionDelayed(scanningOptions.hideDelay, false, false); 406 } 407 } 408 ··· 424 */ 425 _onPopupFramePointerOver() { 426 this._isPointerOverPopup = true; 427 - this._stopClearSelectionDelayed(); 428 } 429 430 /** ··· 432 */ 433 _onPopupFramePointerOut() { 434 this._isPointerOverPopup = false; 435 - const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning; 436 - if (scanningOptions.hidePopupOnCursorExit) { 437 - this._clearSelectionDelayed(scanningOptions.hidePopupOnCursorExitDelay, false, false); 438 } 439 } 440 ··· 457 } 458 459 /** 460 * @param {number} delay 461 * @param {boolean} restart 462 * @param {boolean} passive 463 */ 464 - _clearSelectionDelayed(delay, restart, passive) { 465 if (!this._textScanner.hasSelection()) { return; } 466 if (delay > 0) { 467 if (this._clearSelectionTimer !== null && !restart) { return; } // Already running 468 this._stopClearSelectionDelayed(); 469 - this._clearSelectionTimer = setTimeout(() => { 470 this._clearSelectionTimer = null; 471 - if (this._isPointerOverPopup) { return; } 472 this._clearSelection(passive); 473 }, delay); 474 } else { ··· 602 this._popupEventListeners.removeAllEventListeners(); 603 this._popup = popup; 604 if (popup !== null) { 605 - this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this)); 606 - this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this)); 607 } 608 this._isPointerOverPopup = false; 609 }
··· 402 _onSearchEmpty() { 403 const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning; 404 if (scanningOptions.autoHideResults) { 405 + void this._clearSelectionDelayed(scanningOptions.hideDelay, false, false); 406 } 407 } 408 ··· 424 */ 425 _onPopupFramePointerOver() { 426 this._isPointerOverPopup = true; 427 } 428 429 /** ··· 431 */ 432 _onPopupFramePointerOut() { 433 this._isPointerOverPopup = false; 434 + if (!this._options) { return; } 435 + const {scanning: {hidePopupOnCursorExit, hidePopupOnCursorExitDelay}} = this._options; 436 + if (hidePopupOnCursorExit) { 437 + void this._clearSelectionDelayed(hidePopupOnCursorExitDelay, false, false); 438 } 439 } 440 ··· 457 } 458 459 /** 460 + * Checks if the pointer is over any popup in the hierarchy (parent or child popups). 461 + * @returns {Promise<boolean>} 462 + * @private 463 + */ 464 + async _isPointerOverAnyPopup() { 465 + if (this._isPointerOverPopup) { 466 + return true; 467 + } 468 + 469 + let childPopup = this._popup?.child; 470 + while (typeof childPopup !== 'undefined' && childPopup !== null) { 471 + try { 472 + const isOver = childPopup.isPointerOver(); 473 + if (isOver) { 474 + return true; 475 + } 476 + childPopup = childPopup.child; 477 + } catch (e) { 478 + log.warn(new Error('Error checking child popup pointer state')); 479 + } 480 + } 481 + 482 + let parentPopup = this._popup?.parent; 483 + while (typeof parentPopup !== 'undefined' && parentPopup !== null) { 484 + try { 485 + const isOver = parentPopup.isPointerOver(); 486 + if (isOver) { 487 + return true; 488 + } 489 + parentPopup = parentPopup.parent; 490 + } catch (e) { 491 + log.warn(new Error('Error checking parent popup pointer state')); 492 + } 493 + } 494 + 495 + return false; 496 + } 497 + 498 + /** 499 * @param {number} delay 500 * @param {boolean} restart 501 * @param {boolean} passive 502 */ 503 + async _clearSelectionDelayed(delay, restart, passive) { 504 if (!this._textScanner.hasSelection()) { return; } 505 + 506 + // Add a small delay to allow mouseover events to be processed 507 + await new Promise((resolve) => { 508 + setTimeout(resolve, 50); 509 + }); 510 + 511 + // Always check if pointer is over any popup before clearing 512 + if (await this._isPointerOverAnyPopup()) { return; } 513 + 514 if (delay > 0) { 515 if (this._clearSelectionTimer !== null && !restart) { return; } // Already running 516 this._stopClearSelectionDelayed(); 517 + this._clearSelectionTimer = setTimeout(async () => { 518 this._clearSelectionTimer = null; 519 + if (await this._isPointerOverAnyPopup()) { return; } 520 this._clearSelection(passive); 521 }, delay); 522 } else { ··· 650 this._popupEventListeners.removeAllEventListeners(); 651 this._popup = popup; 652 if (popup !== null) { 653 + this._popupEventListeners.on(popup, 'mouseOver', this._onPopupFramePointerOver.bind(this)); 654 + this._popupEventListeners.on(popup, 'mouseOut', this._onPopupFramePointerOut.bind(this)); 655 } 656 this._isPointerOverPopup = false; 657 }
+7
ext/js/app/popup-factory.js
··· 63 ['popupFactorySetCustomOuterCss', this._onApiSetCustomOuterCss.bind(this)], 64 ['popupFactoryGetFrameSize', this._onApiGetFrameSize.bind(this)], 65 ['popupFactorySetFrameSize', this._onApiSetFrameSize.bind(this)], 66 ]); 67 /* eslint-enable @stylistic/no-multi-spaces */ 68 } ··· 349 async _onApiSetFrameSize({id, width, height}) { 350 const popup = this._getPopup(id); 351 return await popup.setFrameSize(width, height); 352 } 353 354 // Private functions
··· 63 ['popupFactorySetCustomOuterCss', this._onApiSetCustomOuterCss.bind(this)], 64 ['popupFactoryGetFrameSize', this._onApiGetFrameSize.bind(this)], 65 ['popupFactorySetFrameSize', this._onApiSetFrameSize.bind(this)], 66 + ['popupFactoryIsPointerOver', this._onApiIsPointerOver.bind(this)], 67 ]); 68 /* eslint-enable @stylistic/no-multi-spaces */ 69 } ··· 350 async _onApiSetFrameSize({id, width, height}) { 351 const popup = this._getPopup(id); 352 return await popup.setFrameSize(width, height); 353 + } 354 + 355 + /** @type {import('cross-frame-api').ApiHandler<'popupFactoryIsPointerOver'>} */ 356 + async _onApiIsPointerOver({id}) { 357 + const popup = this._getPopup(id); 358 + return popup.isPointerOver(); 359 } 360 361 // Private functions
+8
ext/js/app/popup-proxy.js
··· 295 return this._invokeSafe('popupFactorySetFrameSize', {id: this._id, width, height}, false); 296 } 297 298 // Private 299 300 /**
··· 295 return this._invokeSafe('popupFactorySetFrameSize', {id: this._id, width, height}, false); 296 } 297 298 + /** 299 + * Checks if the pointer is over this popup. 300 + * @returns {Promise<boolean>} Whether the pointer is over the popup 301 + */ 302 + isPointerOver() { 303 + return this._invokeSafe('popupFactoryIsPointerOver', {id: this._id}, false); 304 + } 305 + 306 // Private 307 308 /**
+7
ext/js/app/popup-window.js
··· 263 return false; 264 } 265 266 // Private 267 268 /**
··· 263 return false; 264 } 265 266 + /** 267 + * @returns {Promise<boolean>} 268 + */ 269 + async isPointerOver() { 270 + return false; 271 + } 272 + 273 // Private 274 275 /**
+43 -5
ext/js/app/popup.js
··· 99 this._useShadowDom = true; 100 /** @type {string} */ 101 this._customOuterCss = ''; 102 103 /** @type {?number} */ 104 this._frameSizeContentScale = null; ··· 111 this._frame.style.height = '0'; 112 /** @type {boolean} */ 113 this._frameConnected = false; 114 115 /** @type {HTMLElement} */ 116 this._container = this._frame; ··· 385 * @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid. 386 */ 387 async getFrameSize() { 388 - const {width, height} = this._getFrameBoundingClientRect(); 389 - return {width, height, valid: true}; 390 } 391 392 /** ··· 400 return true; 401 } 402 403 // Private functions 404 405 /** 406 * @returns {void} 407 */ 408 _onFrameMouseOver() { 409 - this.trigger('framePointerOver', {}); 410 } 411 412 /** 413 * @returns {void} 414 */ 415 _onFrameMouseOut() { 416 - this.trigger('framePointerOut', {}); 417 } 418 419 /** ··· 485 this._frameClient = frameClient; 486 await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame); 487 this._frameConnected = true; 488 489 // Configure 490 /** @type {import('display').DirectApiParams<'displayConfigure'>} */ ··· 1016 async _setOptionsContext(optionsContext) { 1017 this._optionsContext = optionsContext; 1018 const options = await this._application.api.optionsGet(optionsContext); 1019 - const {general} = options; 1020 this._themeController.theme = general.popupTheme; 1021 this._themeController.outerTheme = general.popupOuterTheme; 1022 this._themeController.siteOverride = checkPopupPreviewURL(optionsContext.url); ··· 1037 this._useSecureFrameUrl = general.useSecurePopupFrameUrl; 1038 this._useShadowDom = general.usePopupShadowDom; 1039 this._customOuterCss = general.customPopupOuterCss; 1040 void this.updateTheme(); 1041 } 1042
··· 99 this._useShadowDom = true; 100 /** @type {string} */ 101 this._customOuterCss = ''; 102 + /** @type {boolean} */ 103 + this._hidePopupOnCursorExit = false; 104 105 /** @type {?number} */ 106 this._frameSizeContentScale = null; ··· 113 this._frame.style.height = '0'; 114 /** @type {boolean} */ 115 this._frameConnected = false; 116 + /** @type {boolean} */ 117 + this._isPointerOverPopup = false; 118 119 /** @type {HTMLElement} */ 120 this._container = this._frame; ··· 389 * @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid. 390 */ 391 async getFrameSize() { 392 + return {width: this._frame.offsetWidth, height: this._frame.offsetHeight, valid: true}; 393 } 394 395 /** ··· 403 return true; 404 } 405 406 + /** 407 + * Returns whether the pointer is currently over this popup. 408 + * @returns {boolean} 409 + */ 410 + isPointerOver() { 411 + return this._isPointerOverPopup; 412 + } 413 + 414 // Private functions 415 416 /** 417 * @returns {void} 418 */ 419 _onFrameMouseOver() { 420 + this._isPointerOverPopup = true; 421 + 422 + this.trigger('mouseOver', {}); 423 + 424 + if (this._hidePopupOnCursorExit) { 425 + // Clear all child popups when parent is moused over 426 + let currentChild = this.child; 427 + while (currentChild !== null) { 428 + currentChild.hide(false); 429 + currentChild = currentChild.child; 430 + } 431 + } 432 } 433 434 /** 435 * @returns {void} 436 */ 437 _onFrameMouseOut() { 438 + this._isPointerOverPopup = false; 439 + 440 + this.trigger('mouseOut', {}); 441 + 442 + // Propagate mouseOut event up through the entire hierarchy 443 + let currentParent = this.parent; 444 + while (currentParent !== null) { 445 + currentParent.trigger('mouseOut', {}); 446 + currentParent = currentParent.parent; 447 + } 448 } 449 450 /** ··· 516 this._frameClient = frameClient; 517 await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame); 518 this._frameConnected = true; 519 + 520 + // Reattach mouse event listeners after frame injection 521 + const boundMouseOver = this._onFrameMouseOver.bind(this); 522 + const boundMouseOut = this._onFrameMouseOut.bind(this); 523 + this._frame.addEventListener('mouseover', boundMouseOver); 524 + this._frame.addEventListener('mouseout', boundMouseOut); 525 526 // Configure 527 /** @type {import('display').DirectApiParams<'displayConfigure'>} */ ··· 1053 async _setOptionsContext(optionsContext) { 1054 this._optionsContext = optionsContext; 1055 const options = await this._application.api.optionsGet(optionsContext); 1056 + const {general, scanning} = options; 1057 this._themeController.theme = general.popupTheme; 1058 this._themeController.outerTheme = general.popupOuterTheme; 1059 this._themeController.siteOverride = checkPopupPreviewURL(optionsContext.url); ··· 1074 this._useSecureFrameUrl = general.useSecurePopupFrameUrl; 1075 this._useShadowDom = general.usePopupShadowDom; 1076 this._customOuterCss = general.customPopupOuterCss; 1077 + this._hidePopupOnCursorExit = scanning.hidePopupOnCursorExit; 1078 void this.updateTheme(); 1079 } 1080
+7 -1
types/ext/cross-frame-api.d.ts
··· 80 otherFrameId: number; 81 }; 82 83 - type ApiSurface = { 84 displayPopupMessage1: { 85 params: DisplayDirectApiFrameClientMessageAny; 86 return: DisplayDirectApiReturnAny; ··· 233 frameAncestryHandlerRequestFrameInfoResponse: { 234 params: RequestFrameInfoResponseParams; 235 return: RequestFrameInfoResponseReturn; 236 }; 237 }; 238
··· 80 otherFrameId: number; 81 }; 82 83 + export type ApiSurface = { 84 displayPopupMessage1: { 85 params: DisplayDirectApiFrameClientMessageAny; 86 return: DisplayDirectApiReturnAny; ··· 233 frameAncestryHandlerRequestFrameInfoResponse: { 234 params: RequestFrameInfoResponseParams; 235 return: RequestFrameInfoResponseReturn; 236 + }; 237 + popupFactoryIsPointerOver: { 238 + params: { 239 + id: string; 240 + }; 241 + return: boolean; 242 }; 243 }; 244
+2 -2
types/ext/popup.d.ts
··· 96 useWebExtensionApi: boolean; 97 inShadow: boolean; 98 }; 99 - framePointerOver: Record<string, never>; 100 - framePointerOut: Record<string, never>; 101 offsetNotFound: Record<string, never>; 102 }; 103
··· 96 useWebExtensionApi: boolean; 97 inShadow: boolean; 98 }; 99 + mouseOver: Record<string, never>; 100 + mouseOut: Record<string, never>; 101 offsetNotFound: Record<string, never>; 102 }; 103