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 402 _onSearchEmpty() { 403 403 const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning; 404 404 if (scanningOptions.autoHideResults) { 405 - this._clearSelectionDelayed(scanningOptions.hideDelay, false, false); 405 + void this._clearSelectionDelayed(scanningOptions.hideDelay, false, false); 406 406 } 407 407 } 408 408 ··· 424 424 */ 425 425 _onPopupFramePointerOver() { 426 426 this._isPointerOverPopup = true; 427 - this._stopClearSelectionDelayed(); 428 427 } 429 428 430 429 /** ··· 432 431 */ 433 432 _onPopupFramePointerOut() { 434 433 this._isPointerOverPopup = false; 435 - const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning; 436 - if (scanningOptions.hidePopupOnCursorExit) { 437 - this._clearSelectionDelayed(scanningOptions.hidePopupOnCursorExitDelay, false, false); 434 + if (!this._options) { return; } 435 + const {scanning: {hidePopupOnCursorExit, hidePopupOnCursorExitDelay}} = this._options; 436 + if (hidePopupOnCursorExit) { 437 + void this._clearSelectionDelayed(hidePopupOnCursorExitDelay, false, false); 438 438 } 439 439 } 440 440 ··· 457 457 } 458 458 459 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 + /** 460 499 * @param {number} delay 461 500 * @param {boolean} restart 462 501 * @param {boolean} passive 463 502 */ 464 - _clearSelectionDelayed(delay, restart, passive) { 503 + async _clearSelectionDelayed(delay, restart, passive) { 465 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 + 466 514 if (delay > 0) { 467 515 if (this._clearSelectionTimer !== null && !restart) { return; } // Already running 468 516 this._stopClearSelectionDelayed(); 469 - this._clearSelectionTimer = setTimeout(() => { 517 + this._clearSelectionTimer = setTimeout(async () => { 470 518 this._clearSelectionTimer = null; 471 - if (this._isPointerOverPopup) { return; } 519 + if (await this._isPointerOverAnyPopup()) { return; } 472 520 this._clearSelection(passive); 473 521 }, delay); 474 522 } else { ··· 602 650 this._popupEventListeners.removeAllEventListeners(); 603 651 this._popup = popup; 604 652 if (popup !== null) { 605 - this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this)); 606 - this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this)); 653 + this._popupEventListeners.on(popup, 'mouseOver', this._onPopupFramePointerOver.bind(this)); 654 + this._popupEventListeners.on(popup, 'mouseOut', this._onPopupFramePointerOut.bind(this)); 607 655 } 608 656 this._isPointerOverPopup = false; 609 657 }
+7
ext/js/app/popup-factory.js
··· 63 63 ['popupFactorySetCustomOuterCss', this._onApiSetCustomOuterCss.bind(this)], 64 64 ['popupFactoryGetFrameSize', this._onApiGetFrameSize.bind(this)], 65 65 ['popupFactorySetFrameSize', this._onApiSetFrameSize.bind(this)], 66 + ['popupFactoryIsPointerOver', this._onApiIsPointerOver.bind(this)], 66 67 ]); 67 68 /* eslint-enable @stylistic/no-multi-spaces */ 68 69 } ··· 349 350 async _onApiSetFrameSize({id, width, height}) { 350 351 const popup = this._getPopup(id); 351 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(); 352 359 } 353 360 354 361 // Private functions
+8
ext/js/app/popup-proxy.js
··· 295 295 return this._invokeSafe('popupFactorySetFrameSize', {id: this._id, width, height}, false); 296 296 } 297 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 + 298 306 // Private 299 307 300 308 /**
+7
ext/js/app/popup-window.js
··· 263 263 return false; 264 264 } 265 265 266 + /** 267 + * @returns {Promise<boolean>} 268 + */ 269 + async isPointerOver() { 270 + return false; 271 + } 272 + 266 273 // Private 267 274 268 275 /**
+43 -5
ext/js/app/popup.js
··· 99 99 this._useShadowDom = true; 100 100 /** @type {string} */ 101 101 this._customOuterCss = ''; 102 + /** @type {boolean} */ 103 + this._hidePopupOnCursorExit = false; 102 104 103 105 /** @type {?number} */ 104 106 this._frameSizeContentScale = null; ··· 111 113 this._frame.style.height = '0'; 112 114 /** @type {boolean} */ 113 115 this._frameConnected = false; 116 + /** @type {boolean} */ 117 + this._isPointerOverPopup = false; 114 118 115 119 /** @type {HTMLElement} */ 116 120 this._container = this._frame; ··· 385 389 * @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid. 386 390 */ 387 391 async getFrameSize() { 388 - const {width, height} = this._getFrameBoundingClientRect(); 389 - return {width, height, valid: true}; 392 + return {width: this._frame.offsetWidth, height: this._frame.offsetHeight, valid: true}; 390 393 } 391 394 392 395 /** ··· 400 403 return true; 401 404 } 402 405 406 + /** 407 + * Returns whether the pointer is currently over this popup. 408 + * @returns {boolean} 409 + */ 410 + isPointerOver() { 411 + return this._isPointerOverPopup; 412 + } 413 + 403 414 // Private functions 404 415 405 416 /** 406 417 * @returns {void} 407 418 */ 408 419 _onFrameMouseOver() { 409 - this.trigger('framePointerOver', {}); 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 + } 410 432 } 411 433 412 434 /** 413 435 * @returns {void} 414 436 */ 415 437 _onFrameMouseOut() { 416 - this.trigger('framePointerOut', {}); 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 + } 417 448 } 418 449 419 450 /** ··· 485 516 this._frameClient = frameClient; 486 517 await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame); 487 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); 488 525 489 526 // Configure 490 527 /** @type {import('display').DirectApiParams<'displayConfigure'>} */ ··· 1016 1053 async _setOptionsContext(optionsContext) { 1017 1054 this._optionsContext = optionsContext; 1018 1055 const options = await this._application.api.optionsGet(optionsContext); 1019 - const {general} = options; 1056 + const {general, scanning} = options; 1020 1057 this._themeController.theme = general.popupTheme; 1021 1058 this._themeController.outerTheme = general.popupOuterTheme; 1022 1059 this._themeController.siteOverride = checkPopupPreviewURL(optionsContext.url); ··· 1037 1074 this._useSecureFrameUrl = general.useSecurePopupFrameUrl; 1038 1075 this._useShadowDom = general.usePopupShadowDom; 1039 1076 this._customOuterCss = general.customPopupOuterCss; 1077 + this._hidePopupOnCursorExit = scanning.hidePopupOnCursorExit; 1040 1078 void this.updateTheme(); 1041 1079 } 1042 1080
+7 -1
types/ext/cross-frame-api.d.ts
··· 80 80 otherFrameId: number; 81 81 }; 82 82 83 - type ApiSurface = { 83 + export type ApiSurface = { 84 84 displayPopupMessage1: { 85 85 params: DisplayDirectApiFrameClientMessageAny; 86 86 return: DisplayDirectApiReturnAny; ··· 233 233 frameAncestryHandlerRequestFrameInfoResponse: { 234 234 params: RequestFrameInfoResponseParams; 235 235 return: RequestFrameInfoResponseReturn; 236 + }; 237 + popupFactoryIsPointerOver: { 238 + params: { 239 + id: string; 240 + }; 241 + return: boolean; 236 242 }; 237 243 }; 238 244
+2 -2
types/ext/popup.d.ts
··· 96 96 useWebExtensionApi: boolean; 97 97 inShadow: boolean; 98 98 }; 99 - framePointerOver: Record<string, never>; 100 - framePointerOut: Record<string, never>; 99 + mouseOver: Record<string, never>; 100 + mouseOut: Record<string, never>; 101 101 offsetNotFound: Record<string, never>; 102 102 }; 103 103