Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 1711 lines 59 kB view raw
1/* 2 * Copyright (C) 2023-2025 Yomitan Authors 3 * Copyright (C) 2019-2022 Yomichan Authors 4 * 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 */ 18 19import {ThemeController} from '../app/theme-controller.js'; 20import {EventDispatcher} from '../core/event-dispatcher.js'; 21import {EventListenerCollection} from '../core/event-listener-collection.js'; 22import {log} from '../core/log.js'; 23import {safePerformance} from '../core/safe-performance.js'; 24import {clone} from '../core/utilities.js'; 25import {anyNodeMatchesSelector, everyNodeMatchesSelector, getActiveModifiers, getActiveModifiersAndButtons, isPointInSelection} from '../dom/document-util.js'; 26import {TextSourceElement} from '../dom/text-source-element.js'; 27 28const SCAN_RESOLUTION_EXCLUDED_LANGUAGES = new Set(['ja', 'zh', 'yue', 'ko']); 29 30/** 31 * @augments EventDispatcher<import('text-scanner').Events> 32 */ 33export class TextScanner extends EventDispatcher { 34 /** 35 * @param {import('text-scanner').ConstructorDetails} details 36 */ 37 constructor({ 38 api, 39 node, 40 getSearchContext, 41 ignoreElements = null, 42 ignorePoint = null, 43 searchTerms = false, 44 searchKanji = false, 45 searchOnClick = false, 46 searchOnClickOnly = false, 47 textSourceGenerator, 48 }) { 49 super(); 50 /** @type {import('../comm/api.js').API} */ 51 this._api = api; 52 /** @type {HTMLElement|Window} */ 53 this._node = node; 54 /** @type {import('text-scanner').GetSearchContextCallback} */ 55 this._getSearchContext = getSearchContext; 56 /** @type {?(() => Element[])} */ 57 this._ignoreElements = ignoreElements; 58 /** @type {?((x: number, y: number) => Promise<boolean>)} */ 59 this._ignorePoint = ignorePoint; 60 /** @type {boolean} */ 61 this._searchTerms = searchTerms; 62 /** @type {boolean} */ 63 this._searchKanji = searchKanji; 64 /** @type {boolean} */ 65 this._searchOnClick = searchOnClick; 66 /** @type {boolean} */ 67 this._searchOnClickOnly = searchOnClickOnly; 68 /** @type {import('../dom/text-source-generator').TextSourceGenerator} */ 69 this._textSourceGenerator = textSourceGenerator; 70 71 /** @type {boolean} */ 72 this._isPrepared = false; 73 /** @type {?string} */ 74 this._includeSelector = null; 75 /** @type {?string} */ 76 this._excludeSelector = null; 77 /** @type {?string} */ 78 this._touchExcludeSelector = null; 79 /** @type {?string} */ 80 this._language = null; 81 82 /** @type {?import('text-scanner').InputInfo} */ 83 this._inputInfoCurrent = null; 84 /** @type {?Promise<boolean>} */ 85 this._scanTimerPromise = null; 86 /** @type {?(value: boolean) => void} */ 87 this._scanTimerPromiseResolve = null; 88 /** @type {?import('text-source').TextSource} */ 89 this._textSourceCurrent = null; 90 /** @type {boolean} */ 91 this._textSourceCurrentSelected = false; 92 /** @type {boolean} */ 93 this._pendingLookup = false; 94 /** @type {?import('text-scanner').SelectionRestoreInfo} */ 95 this._selectionRestoreInfo = null; 96 97 /** @type {PointerEvent | null} */ 98 this._lastMouseMove = null; 99 100 /** @type {boolean} */ 101 this._deepContentScan = false; 102 /** @type {boolean} */ 103 this._normalizeCssZoom = true; 104 /** @type {boolean} */ 105 this._selectText = false; 106 /** @type {number} */ 107 this._delay = 0; 108 /** @type {number} */ 109 this._scanLength = 1; 110 /** @type {boolean} */ 111 this._layoutAwareScan = false; 112 /** @type {boolean} */ 113 this._preventMiddleMouseOnPage = false; 114 /** @type {boolean} */ 115 this._preventMiddleMouseOnTextHover = false; 116 /** @type {boolean} */ 117 this._preventBackForwardOnPage = false; 118 /** @type {boolean} */ 119 this._preventBackForwardOnTextHover = false; 120 /** @type {number} */ 121 this._sentenceScanExtent = 0; 122 /** @type {boolean} */ 123 this._sentenceTerminateAtNewlines = true; 124 /** @type {import('text-scanner').SentenceTerminatorMap} */ 125 this._sentenceTerminatorMap = new Map(); 126 /** @type {import('text-scanner').SentenceForwardQuoteMap} */ 127 this._sentenceForwardQuoteMap = new Map(); 128 /** @type {import('text-scanner').SentenceBackwardQuoteMap} */ 129 this._sentenceBackwardQuoteMap = new Map(); 130 /** @type {import('text-scanner').InputConfig[]} */ 131 this._inputs = []; 132 133 /** @type {boolean} */ 134 this._enabled = false; 135 /** @type {boolean} */ 136 this._enabledValue = false; 137 /** @type {EventListenerCollection} */ 138 this._eventListeners = new EventListenerCollection(); 139 140 /** @type {boolean} */ 141 this._preventNextClickScan = false; 142 /** @type {?import('core').Timeout} */ 143 this._preventNextClickScanTimer = null; 144 /** @type {number} */ 145 this._preventNextClickScanTimerDuration = 50; 146 /** @type {() => void} */ 147 this._preventNextClickScanTimerCallback = this._onPreventNextClickScanTimeout.bind(this); 148 149 /** @type {boolean} */ 150 this._touchTapValid = false; 151 /** @type {number} */ 152 this._touchPressTime = 0; 153 /** @type {?number} */ 154 this._primaryTouchIdentifier = null; 155 /** @type {boolean} */ 156 this._preventNextContextMenu = false; 157 /** @type {boolean} */ 158 this._preventNextMouseDown = false; 159 /** @type {boolean} */ 160 this._preventNextClick = false; 161 /** @type {boolean} */ 162 this._preventScroll = false; 163 /** @type {import('input').PenPointerState} */ 164 this._penPointerState = 0; 165 /** @type {Map<number, string>} */ 166 this._pointerIdTypeMap = new Map(); 167 168 /** @type {boolean} */ 169 this._canClearSelection = true; 170 171 /** @type {?import('core').Timeout} */ 172 this._textSelectionTimer = null; 173 /** @type {boolean} */ 174 this._yomitanIsChangingTextSelectionNow = false; 175 /** @type {boolean} */ 176 this._userHasNotSelectedAnythingManually = true; 177 /** @type {boolean} */ 178 this._isMouseOverText = false; 179 } 180 181 /** @type {boolean} */ 182 get canClearSelection() { 183 return this._canClearSelection; 184 } 185 186 set canClearSelection(value) { 187 this._canClearSelection = value; 188 } 189 190 /** @type {?string} */ 191 get includeSelector() { 192 return this._includeSelector; 193 } 194 195 set includeSelector(value) { 196 this._includeSelector = value; 197 } 198 199 /** @type {?string} */ 200 get excludeSelector() { 201 return this._excludeSelector; 202 } 203 204 set excludeSelector(value) { 205 this._excludeSelector = value; 206 } 207 208 /** @type {?string} */ 209 get touchEventExcludeSelector() { 210 return this._touchExcludeSelector; 211 } 212 213 set touchEventExcludeSelector(value) { 214 this._touchExcludeSelector = value; 215 } 216 217 /** @type {?string} */ 218 get language() { return this._language; } 219 set language(value) { this._language = value; } 220 221 /** */ 222 prepare() { 223 this._isPrepared = true; 224 this.setEnabled(this._enabled); 225 } 226 227 /** 228 * @returns {boolean} 229 */ 230 isEnabled() { 231 return this._enabled; 232 } 233 234 /** 235 * @param {boolean} enabled 236 */ 237 setEnabled(enabled) { 238 this._enabled = enabled; 239 240 const value = enabled && this._isPrepared; 241 if (this._enabledValue === value) { return; } 242 243 this._eventListeners.removeAllEventListeners(); 244 this._primaryTouchIdentifier = null; 245 this._preventNextContextMenu = false; 246 this._preventNextMouseDown = false; 247 this._preventNextClick = false; 248 this._preventScroll = false; 249 this._penPointerState = 0; 250 this._pointerIdTypeMap.clear(); 251 252 this._enabledValue = value; 253 254 if (value) { 255 this._hookEvents(); 256 this._userHasNotSelectedAnythingManually = this._computeUserHasNotSelectedAnythingManually(); 257 } 258 } 259 260 /** 261 * @param {import('text-scanner').Options} options 262 */ 263 setOptions({ 264 inputs, 265 deepContentScan, 266 normalizeCssZoom, 267 selectText, 268 delay, 269 scanLength, 270 layoutAwareScan, 271 preventMiddleMouseOnPage, 272 preventMiddleMouseOnTextHover, 273 preventBackForwardOnPage, 274 preventBackForwardOnTextHover, 275 sentenceParsingOptions, 276 scanWithoutMousemove, 277 scanResolution, 278 }) { 279 if (Array.isArray(inputs)) { 280 this._inputs = inputs.map((input) => this._convertInput(input)); 281 } 282 if (typeof deepContentScan === 'boolean') { 283 this._deepContentScan = deepContentScan; 284 } 285 if (typeof normalizeCssZoom === 'boolean') { 286 this._normalizeCssZoom = normalizeCssZoom; 287 } 288 if (typeof selectText === 'boolean') { 289 this._selectText = selectText; 290 } 291 if (typeof delay === 'number') { 292 this._delay = delay; 293 } 294 if (typeof scanLength === 'number') { 295 this._scanLength = scanLength; 296 } 297 if (typeof layoutAwareScan === 'boolean') { 298 this._layoutAwareScan = layoutAwareScan; 299 } 300 if (typeof preventMiddleMouseOnPage === 'boolean') { 301 this._preventMiddleMouseOnPage = preventMiddleMouseOnPage; 302 } 303 if (typeof preventMiddleMouseOnTextHover === 'boolean') { 304 this._preventMiddleMouseOnTextHover = preventMiddleMouseOnTextHover; 305 } 306 if (typeof preventBackForwardOnPage === 'boolean') { 307 this._preventBackForwardOnPage = preventBackForwardOnPage; 308 } 309 if (typeof preventBackForwardOnTextHover === 'boolean') { 310 this._preventBackForwardOnTextHover = preventBackForwardOnTextHover; 311 } 312 if (typeof scanWithoutMousemove === 'boolean') { 313 this._scanWithoutMousemove = scanWithoutMousemove; 314 } 315 if (typeof scanResolution === 'string') { 316 this._scanResolution = scanResolution; 317 } 318 if (typeof sentenceParsingOptions === 'object' && sentenceParsingOptions !== null) { 319 const {scanExtent, terminationCharacterMode, terminationCharacters} = sentenceParsingOptions; 320 if (typeof scanExtent === 'number') { 321 this._sentenceScanExtent = scanExtent; 322 } 323 if (typeof terminationCharacterMode === 'string') { 324 this._sentenceTerminateAtNewlines = (terminationCharacterMode === 'custom' || terminationCharacterMode === 'newlines'); 325 const sentenceTerminatorMap = this._sentenceTerminatorMap; 326 const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; 327 const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; 328 sentenceTerminatorMap.clear(); 329 sentenceForwardQuoteMap.clear(); 330 sentenceBackwardQuoteMap.clear(); 331 if ( 332 typeof terminationCharacters === 'object' && 333 Array.isArray(terminationCharacters) && 334 (terminationCharacterMode === 'custom' || terminationCharacterMode === 'custom-no-newlines') 335 ) { 336 for (const {enabled, character1, character2, includeCharacterAtStart, includeCharacterAtEnd} of terminationCharacters) { 337 if (!enabled) { continue; } 338 if (character2 === null) { 339 sentenceTerminatorMap.set(character1, [includeCharacterAtStart, includeCharacterAtEnd]); 340 } else { 341 sentenceForwardQuoteMap.set(character1, [character2, includeCharacterAtStart]); 342 sentenceBackwardQuoteMap.set(character2, [character1, includeCharacterAtEnd]); 343 } 344 } 345 } 346 } 347 } 348 } 349 350 /** 351 * @param {import('text-source').TextSource} textSource 352 * @param {number} length 353 * @param {boolean} layoutAwareScan 354 * @param {import('input').PointerType | undefined} pointerType 355 * @returns {string} 356 */ 357 getTextSourceContent(textSource, length, layoutAwareScan, pointerType) { 358 const clonedTextSource = textSource.clone(); 359 360 clonedTextSource.setEndOffset(length, false, layoutAwareScan); 361 362 const includeSelector = this._includeSelector; 363 const excludeSelector = this._getExcludeSelectorForPointerType(pointerType); 364 if (includeSelector !== null || excludeSelector !== null) { 365 this._constrainTextSource(clonedTextSource, includeSelector, excludeSelector, layoutAwareScan); 366 } 367 368 return clonedTextSource.text(); 369 } 370 371 /** 372 * @returns {boolean} 373 */ 374 hasSelection() { 375 return (this._textSourceCurrent !== null); 376 } 377 378 /** */ 379 clearSelection() { 380 if (!this._canClearSelection) { return; } 381 if (this._textSourceCurrent !== null) { 382 if (this._textSourceCurrentSelected) { 383 this._textSourceCurrent.deselect(); 384 if (this._selectionRestoreInfo !== null) { 385 this._restoreSelection(this._selectionRestoreInfo); 386 this._selectionRestoreInfo = null; 387 } 388 } 389 this._textSourceCurrent = null; 390 this._textSourceCurrentSelected = false; 391 this._inputInfoCurrent = null; 392 } 393 } 394 395 /** */ 396 clearMousePosition() { 397 this._lastMouseMove = null; 398 } 399 400 /** 401 * @returns {?import('text-source').TextSource} 402 */ 403 getCurrentTextSource() { 404 return this._textSourceCurrent; 405 } 406 407 /** 408 * @param {?import('text-source').TextSource} textSource 409 */ 410 setCurrentTextSource(textSource) { 411 this._textSourceCurrent = textSource; 412 if (this._selectText && this._userHasNotSelectedAnythingManually && textSource !== null) { 413 this._yomitanIsChangingTextSelectionNow = true; 414 textSource.select(); 415 if (this._textSelectionTimer !== null) { clearTimeout(this._textSelectionTimer); } 416 // This timeout uses a 50ms delay to ensure that the selectionchange event has time to occur. 417 // If the delay is 0ms, the timeout will sometimes complete before the event. 418 this._textSelectionTimer = setTimeout(() => { 419 this._yomitanIsChangingTextSelectionNow = false; 420 this._textSelectionTimer = null; 421 }, 50); 422 this._textSourceCurrentSelected = true; 423 } else { 424 this._textSourceCurrentSelected = false; 425 } 426 } 427 428 /** 429 * @returns {Promise<boolean>} 430 */ 431 async searchLast() { 432 if (this._textSourceCurrent !== null && this._inputInfoCurrent !== null) { 433 await this._search(this._textSourceCurrent, this._searchTerms, this._searchKanji, this._inputInfoCurrent); 434 return true; 435 } 436 return false; 437 } 438 439 /** 440 * @param {import('text-source').TextSource} textSource 441 * @param {import('text-scanner').InputInfoDetail?} [inputDetail] 442 * @param {boolean} showEmpty shows a "No results found" popup if no results are found 443 * @param {boolean} disallowExpandStartOffset disallows expanding the start offset of the range 444 */ 445 async search(textSource, inputDetail, showEmpty = false, disallowExpandStartOffset = false) { 446 const inputInfo = this._createInputInfo(null, 'script', 'script', true, [], [], inputDetail); 447 await this._search(textSource, this._searchTerms, this._searchKanji, inputInfo, showEmpty, disallowExpandStartOffset); 448 } 449 450 // Private 451 452 /** 453 * @param {import('settings').OptionsContext} baseOptionsContext 454 * @param {import('text-scanner').InputInfo} inputInfo 455 * @returns {import('settings').OptionsContext} 456 */ 457 _createOptionsContextForInput(baseOptionsContext, inputInfo) { 458 const optionsContext = clone(baseOptionsContext); 459 const {modifiers, modifierKeys, pointerType} = inputInfo; 460 optionsContext.modifiers = [...modifiers]; 461 optionsContext.modifierKeys = [...modifierKeys]; 462 optionsContext.pointerType = pointerType; 463 return optionsContext; 464 } 465 466 /** 467 * @param {import('text-source').TextSource} textSource 468 * @param {boolean} searchTerms 469 * @param {boolean} searchKanji 470 * @param {import('text-scanner').InputInfo} inputInfo 471 * @param {boolean} showEmpty shows a "No results found" popup if no results are found 472 * @param {boolean} disallowExpandStartOffset disallows expanding the start offset of the range 473 */ 474 async _search(textSource, searchTerms, searchKanji, inputInfo, showEmpty = false, disallowExpandStartOffset = false) { 475 try { 476 safePerformance.mark('scanner:_search:start'); 477 const isAltText = textSource instanceof TextSourceElement; 478 if (inputInfo.pointerType === 'touch') { 479 if (isAltText) { 480 return; 481 } 482 const {imposterSourceElement, rangeStartOffset} = textSource; 483 if (imposterSourceElement instanceof HTMLTextAreaElement || imposterSourceElement instanceof HTMLInputElement) { 484 const isFocused = imposterSourceElement === document.activeElement; 485 if (!isFocused || imposterSourceElement.selectionStart !== rangeStartOffset) { 486 return; 487 } 488 } 489 } 490 491 const inputInfoDetail = inputInfo.detail; 492 const selectionRestoreInfo = ( 493 (typeof inputInfoDetail === 'object' && inputInfoDetail !== null && inputInfoDetail.restoreSelection) ? 494 (this._inputInfoCurrent === null ? this._createSelectionRestoreInfo() : null) : 495 null 496 ); 497 498 if (this._scanResolution === 'word' && !disallowExpandStartOffset && 499 (this._language === null || !SCAN_RESOLUTION_EXCLUDED_LANGUAGES.has(this._language))) { 500 // Move the start offset to the beginning of the word 501 textSource.setStartOffset(this._scanLength, this._layoutAwareScan, true); 502 } 503 504 if (this._textSourceCurrent !== null && this._textSourceCurrent.hasSameStart(textSource)) { 505 return; 506 } 507 508 const getSearchContextPromise = this._getSearchContext(); 509 const getSearchContextResult = getSearchContextPromise instanceof Promise ? await getSearchContextPromise : getSearchContextPromise; 510 const {detail} = getSearchContextResult; 511 const optionsContext = this._createOptionsContextForInput(getSearchContextResult.optionsContext, inputInfo); 512 513 /** @type {?import('dictionary').DictionaryEntry[]} */ 514 let dictionaryEntries = null; 515 /** @type {?import('display').HistoryStateSentence} */ 516 let sentence = null; 517 /** @type {'terms'|'kanji'} */ 518 let type = 'terms'; 519 const result = await this._findDictionaryEntries(textSource, searchTerms, searchKanji, optionsContext); 520 if (result !== null) { 521 ({dictionaryEntries, sentence, type} = result); 522 } else if (showEmpty || (textSource !== null && isAltText && await this._isTextLookupWorthy(textSource.content))) { 523 // Shows a "No results found" message 524 dictionaryEntries = []; 525 sentence = {text: '', offset: 0}; 526 } 527 528 if (dictionaryEntries !== null && sentence !== null) { 529 this._inputInfoCurrent = inputInfo; 530 this.setCurrentTextSource(textSource); 531 this._selectionRestoreInfo = selectionRestoreInfo; 532 533 /** @type {ThemeController} */ 534 this._themeController = new ThemeController(document.documentElement); 535 const pageTheme = this._themeController.computeSiteTheme(); 536 537 this.trigger('searchSuccess', { 538 type, 539 dictionaryEntries, 540 sentence, 541 inputInfo, 542 textSource, 543 optionsContext, 544 detail, 545 pageTheme, 546 }); 547 } else { 548 this._triggerSearchEmpty(inputInfo); 549 } 550 safePerformance.mark('scanner:_search:end'); 551 safePerformance.measure('scanner:_search', 'scanner:_search:start', 'scanner:_search:end'); 552 } catch (error) { 553 this.trigger('searchError', { 554 error: error instanceof Error ? error : new Error(`A search error occurred: ${error}`), 555 textSource, 556 inputInfo, 557 }); 558 } 559 } 560 561 /** 562 * @param {import('text-scanner').InputInfo} inputInfo 563 */ 564 _triggerSearchEmpty(inputInfo) { 565 this.trigger('searchEmpty', {inputInfo}); 566 } 567 568 /** */ 569 _resetPreventNextClickScan() { 570 this._preventNextClickScan = false; 571 if (this._preventNextClickScanTimer !== null) { clearTimeout(this._preventNextClickScanTimer); } 572 this._preventNextClickScanTimer = setTimeout(this._preventNextClickScanTimerCallback, this._preventNextClickScanTimerDuration); 573 } 574 575 /** */ 576 _onPreventNextClickScanTimeout() { 577 this._preventNextClickScanTimer = null; 578 } 579 580 /** */ 581 _onSelectionChange() { 582 if (this._preventNextClickScanTimer !== null) { return; } // Ignore deselection that occurs at the start of the click 583 this._preventNextClickScan = true; 584 } 585 586 /** */ 587 _onSelectionChangeCheckUserSelection() { 588 if (this._yomitanIsChangingTextSelectionNow) { return; } 589 this._userHasNotSelectedAnythingManually = this._computeUserHasNotSelectedAnythingManually(); 590 } 591 592 /** 593 * @param {PointerEvent} e 594 */ 595 _onSearchClickPointerDown(e) { 596 if (e.button !== 0) { return; } 597 this._resetPreventNextClickScan(); 598 } 599 600 /** 601 * @param {import('input').Modifier[]} activeModifiers 602 * @returns {boolean} 603 */ 604 _modifierKeySet(activeModifiers) { 605 /** @type {string[]} */ 606 const settingsModifiers = []; 607 for (const settingsInput of this._inputs) { 608 settingsModifiers.push(...settingsInput.include); 609 } 610 return activeModifiers.some((modifier) => settingsModifiers.includes(modifier)); 611 } 612 613 /** 614 * @param {KeyboardEvent} e 615 */ 616 _onKeyDown(e) { 617 const modifiers = getActiveModifiers(e); 618 if (this._lastMouseMove !== null && modifiers.length > 0 && this._modifierKeySet(modifiers)) { 619 if (this._inputtingText()) { return; } 620 const syntheticMousePointerEvent = new PointerEvent(this._lastMouseMove.type, { 621 screenX: this._lastMouseMove.screenX, 622 screenY: this._lastMouseMove.screenY, 623 clientX: this._lastMouseMove.clientX, 624 clientY: this._lastMouseMove.clientY, 625 ctrlKey: modifiers.includes('ctrl'), 626 shiftKey: modifiers.includes('shift'), 627 altKey: modifiers.includes('alt'), 628 metaKey: modifiers.includes('meta'), 629 button: this._lastMouseMove.button, 630 buttons: this._lastMouseMove.buttons, 631 relatedTarget: this._lastMouseMove.relatedTarget, 632 }); 633 this._onMousePointerMove(syntheticMousePointerEvent); 634 } 635 } 636 637 /** 638 * @returns {boolean} 639 */ 640 _inputtingText() { 641 const activeElement = document.activeElement; 642 if (activeElement && activeElement instanceof HTMLElement) { 643 if (activeElement.nodeName === 'INPUT' || activeElement.nodeName === 'TEXTAREA') { return true; } 644 if (activeElement.isContentEditable) { return true; } 645 } 646 return false; 647 } 648 649 /** 650 * @param {PointerEvent} e 651 * @returns {boolean|void} 652 */ 653 _onMouseUp(e) { 654 switch (e.button) { 655 case 3: // Back 656 case 4: // Forward 657 if (this._preventBackForwardOnPage || (this._preventBackForwardOnTextHover && this._isMouseOverText)) { 658 e.preventDefault(); 659 e.stopPropagation(); 660 } 661 break; 662 } 663 } 664 665 /** 666 * @param {PointerEvent} e 667 * @returns {boolean|void} 668 */ 669 _onMouseDown(e) { 670 if (this._preventNextMouseDown) { 671 this._preventNextMouseDown = false; 672 this._preventNextClick = true; 673 e.preventDefault(); 674 e.stopPropagation(); 675 return false; 676 } 677 678 switch (e.button) { 679 case 0: // Primary 680 if (this._searchOnClick) { this._resetPreventNextClickScan(); } 681 this._scanTimerClear(); 682 this._triggerClear('mousedown'); 683 break; 684 case 1: // Middle 685 if (this._preventMiddleMouseOnPage || (this._preventMiddleMouseOnTextHover && this._isMouseOverText)) { 686 e.preventDefault(); 687 e.stopPropagation(); 688 } 689 break; 690 } 691 692 this._onMousePointerMove(e); 693 } 694 695 /** */ 696 _onMouseOut() { 697 this._scanTimerClear(); 698 this.clearMousePosition(); 699 } 700 701 /** 702 * @param {PointerEvent} e 703 * @returns {boolean|void} 704 */ 705 _onClick(e) { 706 if (this._preventNextClick) { 707 this._preventNextClick = false; 708 e.preventDefault(); 709 e.stopPropagation(); 710 return false; 711 } 712 713 if (this._searchOnClick) { 714 this._onSearchClick(e); 715 return; 716 } 717 718 this._onMousePointerMove(e); 719 } 720 721 /** 722 * @param {PointerEvent} e 723 */ 724 _onSearchClick(e) { 725 const preventNextClickScan = this._preventNextClickScan; 726 this._preventNextClickScan = false; 727 if (this._preventNextClickScanTimer !== null) { 728 clearTimeout(this._preventNextClickScanTimer); 729 this._preventNextClickScanTimer = null; 730 } 731 732 if (preventNextClickScan) { return; } 733 734 const modifiers = getActiveModifiersAndButtons(e); 735 const modifierKeys = getActiveModifiers(e); 736 const inputInfo = this._createInputInfo(null, 'mouse', 'click', false, modifiers, modifierKeys); 737 void this._searchAt(e.clientX, e.clientY, inputInfo); 738 } 739 740 /** 741 * @param {PointerEvent} e 742 * @returns {boolean|void} 743 */ 744 _onAuxClick(e) { 745 this._preventNextContextMenu = false; 746 switch (e.button) { 747 case 1: // Middle 748 if (this._preventMiddleMouseOnPage || (this._preventMiddleMouseOnTextHover && this._isMouseOverText)) { 749 e.preventDefault(); 750 e.stopPropagation(); 751 } 752 break; 753 } 754 } 755 756 /** 757 * @param {PointerEvent} e 758 * @returns {boolean|void} 759 */ 760 _onContextMenu(e) { 761 if (this._preventNextContextMenu) { 762 this._preventNextContextMenu = false; 763 e.preventDefault(); 764 e.stopPropagation(); 765 return false; 766 } 767 } 768 769 /** 770 * @param {TouchEvent|PointerEvent} e 771 * @param {number} x 772 * @param {number} y 773 * @param {number} identifier 774 */ 775 _onPrimaryTouchStart(e, x, y, identifier) { 776 this._preventScroll = false; 777 this._preventNextContextMenu = false; 778 this._preventNextMouseDown = false; 779 this._preventNextClick = false; 780 this._touchTapValid = true; 781 this._touchPressTime = Date.now(); 782 783 const languageNotNull = this._language !== null ? this._language : ''; 784 const selection = window.getSelection(); 785 if (selection !== null && isPointInSelection(x, y, selection, languageNotNull)) { 786 return; 787 } 788 789 this._primaryTouchIdentifier = identifier; 790 791 if (this._pendingLookup) { return; } 792 793 const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchStart', e); 794 if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchPress)) { return; } 795 796 void this._searchAtFromTouchStart(x, y, inputInfo); 797 } 798 799 /** 800 * @param {TouchEvent} e 801 */ 802 _onTouchEnd(e) { 803 if (this._primaryTouchIdentifier === null) { return; } 804 805 const primaryTouch = this._getTouch(e.changedTouches, this._primaryTouchIdentifier); 806 if (primaryTouch === null) { return; } 807 808 const {clientX, clientY} = primaryTouch; 809 this._onPrimaryTouchEnd(e, clientX, clientY, true); 810 } 811 812 /** 813 * @param {TouchEvent|PointerEvent} e 814 * @param {number} x 815 * @param {number} y 816 * @param {boolean} allowSearch 817 */ 818 _onPrimaryTouchEnd(e, x, y, allowSearch) { 819 const touchReleaseTime = Date.now(); 820 this._primaryTouchIdentifier = null; 821 this._preventScroll = false; 822 this._preventNextClick = false; 823 // Don't revert context menu and mouse down prevention, since these events can occur after the touch has ended. 824 // I.e. this._preventNextContextMenu and this._preventNextMouseDown should not be assigned to false. 825 826 if (!allowSearch) { return; } 827 828 const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchEnd', e); 829 if (inputInfo === null || inputInfo.input === null) { return; } 830 if (touchReleaseTime - this._touchPressTime < inputInfo.input.minimumTouchTime) { return; } 831 if (inputInfo.input.scanOnTouchRelease || (inputInfo.input.scanOnTouchTap && this._touchTapValid)) { 832 void this._searchAtFromTouchEnd(x, y, inputInfo); 833 } 834 } 835 836 /** 837 * @param {PointerEvent} e 838 * @returns {void} 839 */ 840 _onPointerOver(e) { 841 const {pointerType, pointerId, isPrimary} = e; 842 if (pointerType === 'pen') { 843 this._pointerIdTypeMap.set(pointerId, pointerType); 844 } 845 846 if (!isPrimary) { return; } 847 switch (pointerType) { 848 case 'mouse': return this._onMousePointerOver(e); 849 case 'touch': return this._onTouchPointerOver(); 850 case 'pen': return this._onPenPointerOver(e); 851 } 852 } 853 854 /** 855 * @param {PointerEvent} e 856 * @returns {boolean|void} 857 */ 858 _onPointerDown(e) { 859 if (!e.isPrimary) { return; } 860 switch (this._getPointerEventType(e)) { 861 case 'mouse': return this._onMousePointerDown(e); 862 case 'touch': return this._onTouchPointerDown(e); 863 case 'pen': return this._onPenPointerDown(e); 864 } 865 } 866 867 /** 868 * @param {PointerEvent} e 869 * @returns {void} 870 */ 871 _onPointerMove(e) { 872 if (!e.isPrimary) { return; } 873 switch (this._getPointerEventType(e)) { 874 case 'mouse': return this._onMousePointerMove(e); 875 case 'touch': return this._onTouchPointerMove(e); 876 case 'pen': return this._onPenPointerMove(e); 877 } 878 } 879 880 /** 881 * @param {PointerEvent} e 882 * @returns {void} 883 */ 884 _onPointerUp(e) { 885 if (!e.isPrimary) { return; } 886 switch (this._getPointerEventType(e)) { 887 case 'mouse': return this._onMousePointerUp(); 888 case 'touch': return this._onTouchPointerUp(e); 889 case 'pen': return this._onPenPointerUp(e); 890 } 891 } 892 893 /** 894 * @param {PointerEvent} e 895 * @returns {void} 896 */ 897 _onPointerCancel(e) { 898 this._pointerIdTypeMap.delete(e.pointerId); 899 if (!e.isPrimary) { return; } 900 switch (e.pointerType) { 901 case 'mouse': return this._onMousePointerCancel(); 902 case 'touch': return this._onTouchPointerCancel(e); 903 case 'pen': return this._onPenPointerCancel(); 904 } 905 } 906 907 /** 908 * @param {PointerEvent} e 909 * @returns {void} 910 */ 911 _onPointerOut(e) { 912 this._pointerIdTypeMap.delete(e.pointerId); 913 if (!e.isPrimary) { return; } 914 switch (e.pointerType) { 915 case 'mouse': return this._onMousePointerOut(); 916 case 'touch': return this._onTouchPointerOut(); 917 case 'pen': return this._onPenPointerOut(); 918 } 919 } 920 921 /** 922 * @param {PointerEvent} e 923 * @returns {void} 924 */ 925 _onMousePointerOver(e) { 926 if (this._ignoreElements !== null && this._ignoreElements().includes(/** @type {Element} */ (e.target))) { 927 this._scanTimerClear(); 928 } 929 } 930 931 /** 932 * @param {PointerEvent} e 933 * @returns {boolean|void} 934 */ 935 _onMousePointerDown(e) { 936 return this._onMouseDown(e); 937 } 938 939 /** 940 * @param {PointerEvent} e 941 * @returns {void} 942 */ 943 _onMousePointerMove(e) { 944 this._scanTimerClear(); 945 this._lastMouseMove = e; 946 947 const inputInfo = this._getMatchingInputGroupFromEvent('mouse', 'mouseMove', e); 948 if (inputInfo === null) { return; } 949 950 void this._searchAtFromMouseMove(e.clientX, e.clientY, inputInfo); 951 } 952 953 /** */ 954 _onMousePointerUp() { 955 // NOP 956 } 957 958 /** 959 * @returns {void} 960 */ 961 _onMousePointerCancel() { 962 this._onMouseOut(); 963 } 964 965 /** 966 * @returns {void} 967 */ 968 _onMousePointerOut() { 969 this._onMouseOut(); 970 } 971 972 /** */ 973 _onTouchPointerOver() { 974 // NOP 975 } 976 977 /** 978 * @param {PointerEvent} e 979 * @returns {void} 980 */ 981 _onTouchPointerDown(e) { 982 const {clientX, clientY, pointerId} = e; 983 this._onPrimaryTouchStart(e, clientX, clientY, pointerId); 984 } 985 986 /** 987 * @param {PointerEvent} e 988 * @returns {void} 989 */ 990 _onTouchPointerMove(e) { 991 if (!this._preventScroll || !e.cancelable) { 992 return; 993 } 994 this._touchTapValid = false; 995 996 const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e); 997 if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchMove)) { return; } 998 999 void this._searchAt(e.clientX, e.clientY, inputInfo); 1000 } 1001 1002 /** 1003 * @param {PointerEvent} e 1004 * @returns {void} 1005 */ 1006 _onTouchPointerUp(e) { 1007 const {clientX, clientY} = e; 1008 this._onPrimaryTouchEnd(e, clientX, clientY, true); 1009 } 1010 1011 /** 1012 * @param {PointerEvent} e 1013 * @returns {void} 1014 */ 1015 _onTouchPointerCancel(e) { 1016 this._onPrimaryTouchEnd(e, 0, 0, false); 1017 } 1018 1019 /** */ 1020 _onTouchPointerOut() { 1021 // NOP 1022 } 1023 1024 /** 1025 * @param {PointerEvent} e 1026 */ 1027 _onTouchMove(e) { 1028 this._touchTapValid = false; 1029 1030 if (!this._preventScroll) { return; } 1031 1032 if (e.cancelable) { 1033 e.preventDefault(); 1034 } else { 1035 this._preventScroll = false; 1036 } 1037 } 1038 1039 /** 1040 * @param {PointerEvent} e 1041 */ 1042 _onPenPointerOver(e) { 1043 this._penPointerState = 1; 1044 void this._searchAtFromPen(e, 'pointerOver', false); 1045 } 1046 1047 /** 1048 * @param {PointerEvent} e 1049 */ 1050 _onPenPointerDown(e) { 1051 this._penPointerState = 2; 1052 void this._searchAtFromPen(e, 'pointerDown', true); 1053 } 1054 1055 /** 1056 * @param {PointerEvent} e 1057 */ 1058 _onPenPointerMove(e) { 1059 if (this._penPointerState === 2 && (!this._preventScroll || !e.cancelable)) { return; } 1060 void this._searchAtFromPen(e, 'pointerMove', true); 1061 } 1062 1063 /** 1064 * @param {PointerEvent} e 1065 */ 1066 _onPenPointerUp(e) { 1067 this._penPointerState = 3; 1068 this._preventScroll = false; 1069 void this._searchAtFromPen(e, 'pointerUp', false); 1070 } 1071 1072 /** */ 1073 _onPenPointerCancel() { 1074 this._onPenPointerOut(); 1075 } 1076 1077 /** */ 1078 _onPenPointerOut() { 1079 this._penPointerState = 0; 1080 this._preventScroll = false; 1081 this._preventNextContextMenu = false; 1082 this._preventNextMouseDown = false; 1083 this._preventNextClick = false; 1084 } 1085 1086 /** 1087 * @returns {Promise<boolean>} 1088 */ 1089 async _scanTimerWait() { 1090 const delay = this._delay; 1091 const promise = /** @type {Promise<boolean>} */ (new Promise((resolve) => { 1092 /** @type {?import('core').Timeout} */ 1093 let timeout = setTimeout(() => { 1094 timeout = null; 1095 resolve(true); 1096 }, delay); 1097 this._scanTimerPromiseResolve = (value) => { 1098 if (timeout === null) { return; } 1099 clearTimeout(timeout); 1100 timeout = null; 1101 resolve(value); 1102 }; 1103 })); 1104 this._scanTimerPromise = promise; 1105 try { 1106 return await promise; 1107 } finally { 1108 if (this._scanTimerPromise === promise) { 1109 this._scanTimerPromise = null; 1110 this._scanTimerPromiseResolve = null; 1111 } 1112 } 1113 } 1114 1115 /** */ 1116 _scanTimerClear() { 1117 if (this._scanTimerPromiseResolve === null) { return; } 1118 this._scanTimerPromiseResolve(false); 1119 this._scanTimerPromiseResolve = null; 1120 this._scanTimerPromise = null; 1121 } 1122 1123 /** */ 1124 _hookEvents() { 1125 const capture = true; 1126 /** @type {import('event-listener-collection').AddEventListenerArgs[]} */ 1127 const eventListenerInfos = []; 1128 eventListenerInfos.push(...this._getClickEventListeners(capture)); 1129 if (!this._searchOnClickOnly) { 1130 eventListenerInfos.push(...this._getPointerEventListeners(capture)); 1131 if (this._scanWithoutMousemove) { 1132 eventListenerInfos.push(...this._getKeyboardEventListeners(capture)); 1133 } 1134 } 1135 if (this._searchOnClick) { 1136 eventListenerInfos.push(...this._getSearchOnClickEventListeners(capture)); 1137 } 1138 1139 eventListenerInfos.push(this._getSelectionChangeCheckUserSelectionListener()); 1140 1141 for (const args of eventListenerInfos) { 1142 this._eventListeners.addEventListener(...args); 1143 } 1144 } 1145 1146 /** 1147 * @param {boolean} capture 1148 * @returns {import('event-listener-collection').AddEventListenerArgs[]} 1149 */ 1150 _getPointerEventListeners(capture) { 1151 return [ 1152 [this._node, 'pointerover', this._onPointerOver.bind(this), capture], 1153 [this._node, 'pointerdown', this._onPointerDown.bind(this), capture], 1154 [this._node, 'pointermove', this._onPointerMove.bind(this), capture], 1155 [this._node, 'pointerup', this._onPointerUp.bind(this), capture], 1156 [this._node, 'pointercancel', this._onPointerCancel.bind(this), capture], 1157 [this._node, 'pointerout', this._onPointerOut.bind(this), capture], 1158 [this._node, 'mouseup', this._onMouseUp.bind(this), capture], 1159 [this._node, 'mousedown', this._onMouseDown.bind(this), capture], 1160 [this._node, 'touchmove', this._onTouchMove.bind(this), {passive: false, capture}], 1161 [this._node, 'touchend', this._onTouchEnd.bind(this), capture], 1162 [this._node, 'auxclick', this._onAuxClick.bind(this), capture], 1163 [this._node, 'contextmenu', this._onContextMenu.bind(this), capture], 1164 ]; 1165 } 1166 1167 /** 1168 * @param {boolean} capture 1169 * @returns {import('event-listener-collection').AddEventListenerArgs[]} 1170 */ 1171 _getKeyboardEventListeners(capture) { 1172 return [ 1173 [this._node, 'keydown', this._onKeyDown.bind(this), capture], 1174 ]; 1175 } 1176 1177 /** 1178 * @param {boolean} capture 1179 * @returns {import('event-listener-collection').AddEventListenerArgs[]} 1180 */ 1181 _getClickEventListeners(capture) { 1182 return [ 1183 [this._node, 'click', this._onClick.bind(this), capture], 1184 ]; 1185 } 1186 1187 /** 1188 * @param {boolean} capture 1189 * @returns {import('event-listener-collection').AddEventListenerArgs[]} 1190 */ 1191 _getSearchOnClickEventListeners(capture) { 1192 const {documentElement} = document; 1193 /** @type {import('event-listener-collection').AddEventListenerArgs[]} */ 1194 const entries = [ 1195 [document, 'selectionchange', this._onSelectionChange.bind(this)], 1196 ]; 1197 if (documentElement !== null) { 1198 entries.push([documentElement, 'pointerdown', this._onSearchClickPointerDown.bind(this), capture]); 1199 } 1200 return entries; 1201 } 1202 1203 /** 1204 * @returns {import('event-listener-collection').AddEventListenerArgs} 1205 */ 1206 _getSelectionChangeCheckUserSelectionListener() { 1207 return [document, 'selectionchange', this._onSelectionChangeCheckUserSelection.bind(this)]; 1208 } 1209 1210 /** 1211 * @param {TouchList} touchList 1212 * @param {number} identifier 1213 * @returns {?Touch} 1214 */ 1215 _getTouch(touchList, identifier) { 1216 for (const touch of touchList) { 1217 if (touch.identifier === identifier) { 1218 return touch; 1219 } 1220 } 1221 return null; 1222 } 1223 1224 /** 1225 * @param {import('text-source').TextSource} textSource 1226 * @param {boolean} searchTerms 1227 * @param {boolean} searchKanji 1228 * @param {import('settings').OptionsContext} optionsContext 1229 * @returns {Promise<?import('text-scanner').SearchResults>} 1230 */ 1231 async _findDictionaryEntries(textSource, searchTerms, searchKanji, optionsContext) { 1232 if (textSource === null) { 1233 return null; 1234 } 1235 if (searchTerms) { 1236 const results = await this._findTermDictionaryEntries(textSource, optionsContext); 1237 if (results !== null) { return results; } 1238 } 1239 if (searchKanji) { 1240 const results = await this._findKanjiDictionaryEntries(textSource, optionsContext); 1241 if (results !== null) { return results; } 1242 } 1243 return null; 1244 } 1245 1246 /** 1247 * @param {import('text-source').TextSource} textSource 1248 * @param {import('settings').OptionsContext} optionsContext 1249 * @returns {Promise<?import('text-scanner').TermSearchResults>} 1250 */ 1251 async _findTermDictionaryEntries(textSource, optionsContext) { 1252 const scanLength = this._scanLength; 1253 const sentenceScanExtent = this._sentenceScanExtent; 1254 const sentenceTerminateAtNewlines = this._sentenceTerminateAtNewlines; 1255 const sentenceTerminatorMap = this._sentenceTerminatorMap; 1256 const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; 1257 const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; 1258 const layoutAwareScan = this._layoutAwareScan; 1259 const searchText = this.getTextSourceContent(textSource, scanLength, layoutAwareScan, optionsContext.pointerType); 1260 if (searchText.length === 0) { return null; } 1261 1262 /** @type {import('api').FindTermsDetails} */ 1263 const details = {}; 1264 const {dictionaryEntries, originalTextLength} = await this._api.termsFind(searchText, details, optionsContext); 1265 if (dictionaryEntries.length === 0) { return null; } 1266 1267 textSource.setEndOffset(originalTextLength, false, layoutAwareScan); 1268 const sentence = this._textSourceGenerator.extractSentence( 1269 textSource, 1270 layoutAwareScan, 1271 sentenceScanExtent, 1272 sentenceTerminateAtNewlines, 1273 sentenceTerminatorMap, 1274 sentenceForwardQuoteMap, 1275 sentenceBackwardQuoteMap, 1276 ); 1277 1278 return {dictionaryEntries, sentence, type: 'terms'}; 1279 } 1280 1281 /** 1282 * @param {import('text-source').TextSource} textSource 1283 * @param {import('settings').OptionsContext} optionsContext 1284 * @returns {Promise<?import('text-scanner').KanjiSearchResults>} 1285 */ 1286 async _findKanjiDictionaryEntries(textSource, optionsContext) { 1287 const sentenceScanExtent = this._sentenceScanExtent; 1288 const sentenceTerminateAtNewlines = this._sentenceTerminateAtNewlines; 1289 const sentenceTerminatorMap = this._sentenceTerminatorMap; 1290 const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; 1291 const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; 1292 const layoutAwareScan = this._layoutAwareScan; 1293 const searchText = this.getTextSourceContent(textSource, 1, layoutAwareScan, optionsContext.pointerType); 1294 if (searchText.length === 0) { return null; } 1295 1296 const dictionaryEntries = await this._api.kanjiFind(searchText, optionsContext); 1297 if (dictionaryEntries.length === 0) { return null; } 1298 1299 textSource.setEndOffset(1, false, layoutAwareScan); 1300 const sentence = this._textSourceGenerator.extractSentence( 1301 textSource, 1302 layoutAwareScan, 1303 sentenceScanExtent, 1304 sentenceTerminateAtNewlines, 1305 sentenceTerminatorMap, 1306 sentenceForwardQuoteMap, 1307 sentenceBackwardQuoteMap, 1308 ); 1309 1310 return {dictionaryEntries, sentence, type: 'kanji'}; 1311 } 1312 1313 /** 1314 * @param {number} x 1315 * @param {number} y 1316 * @param {import('text-scanner').InputInfo} inputInfo 1317 */ 1318 async _searchAt(x, y, inputInfo) { 1319 if (this._pendingLookup) { return; } 1320 1321 try { 1322 safePerformance.mark('scanner:_searchAt:start'); 1323 const sourceInput = inputInfo.input; 1324 let searchTerms = this._searchTerms; 1325 let searchKanji = this._searchKanji; 1326 if (sourceInput !== null) { 1327 if (searchTerms && !sourceInput.searchTerms) { searchTerms = false; } 1328 if (searchKanji && !sourceInput.searchKanji) { searchKanji = false; } 1329 } 1330 1331 this._pendingLookup = true; 1332 this._scanTimerClear(); 1333 1334 if (typeof this._ignorePoint === 'function' && await this._ignorePoint(x, y)) { 1335 return; 1336 } 1337 1338 const textSource = this._textSourceGenerator.getRangeFromPoint(x, y, { 1339 deepContentScan: this._deepContentScan, 1340 normalizeCssZoom: this._normalizeCssZoom, 1341 language: this._language, 1342 }); 1343 if (textSource !== null) { 1344 try { 1345 this._isMouseOverText = true; 1346 await this._search(textSource, searchTerms, searchKanji, inputInfo); 1347 } finally { 1348 textSource.cleanup(); 1349 } 1350 } else { 1351 this._isMouseOverText = false; 1352 this._triggerSearchEmpty(inputInfo); 1353 } 1354 safePerformance.mark('scanner:_searchAt:end'); 1355 safePerformance.measure('scanner:_searchAt', 'scanner:_searchAt:start', 'scanner:_searchAt:end'); 1356 } catch (e) { 1357 log.error(e); 1358 } finally { 1359 this._pendingLookup = false; 1360 } 1361 } 1362 1363 /** 1364 * @param {number} x 1365 * @param {number} y 1366 * @param {import('text-scanner').InputInfo} inputInfo 1367 */ 1368 async _searchAtFromMouseMove(x, y, inputInfo) { 1369 if (this._pendingLookup) { return; } 1370 1371 if (inputInfo.passive && !await this._scanTimerWait()) { 1372 // Aborted 1373 return; 1374 } 1375 1376 await this._searchAt(x, y, inputInfo); 1377 } 1378 1379 /** 1380 * @param {number} x 1381 * @param {number} y 1382 * @param {import('text-scanner').InputInfo} inputInfo 1383 */ 1384 async _searchAtFromTouchStart(x, y, inputInfo) { 1385 const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null; 1386 const {input} = inputInfo; 1387 const preventScroll = input !== null && input.preventTouchScrolling; 1388 1389 await this._searchAt(x, y, inputInfo); 1390 1391 if ( 1392 this._textSourceCurrent !== null && 1393 !(textSourceCurrentPrevious !== null && this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious)) 1394 ) { 1395 this._preventScroll = preventScroll; 1396 this._preventNextContextMenu = true; 1397 this._preventNextMouseDown = true; 1398 } 1399 } 1400 1401 /** 1402 * @param {number} x 1403 * @param {number} y 1404 * @param {import('text-scanner').InputInfo} inputInfo 1405 */ 1406 async _searchAtFromTouchEnd(x, y, inputInfo) { 1407 const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null; 1408 1409 await this._searchAt(x, y, inputInfo); 1410 1411 if ( 1412 this._textSourceCurrent !== null && 1413 !(textSourceCurrentPrevious !== null && this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious)) 1414 ) { 1415 this._preventNextMouseDown = true; 1416 } 1417 } 1418 1419 /** 1420 * @param {PointerEvent} e 1421 * @param {import('input').PointerEventType} eventType 1422 * @param {boolean} prevent 1423 */ 1424 async _searchAtFromPen(e, eventType, prevent) { 1425 if (this._pendingLookup) { return; } 1426 1427 const inputInfo = this._getMatchingInputGroupFromEvent('pen', eventType, e); 1428 if (inputInfo === null) { return; } 1429 1430 const {input} = inputInfo; 1431 if (input === null || !this._isPenEventSupported(eventType, input)) { return; } 1432 1433 const preventScroll = input !== null && input.preventPenScrolling; 1434 1435 await this._searchAt(e.clientX, e.clientY, inputInfo); 1436 1437 if ( 1438 prevent && 1439 this._textSourceCurrent !== null 1440 ) { 1441 this._preventScroll = preventScroll; 1442 this._preventNextContextMenu = true; 1443 this._preventNextMouseDown = true; 1444 this._preventNextClick = true; 1445 } 1446 } 1447 1448 /** 1449 * @param {import('input').PointerEventType} eventType 1450 * @param {import('text-scanner').InputConfig} input 1451 * @returns {boolean} 1452 */ 1453 _isPenEventSupported(eventType, input) { 1454 switch (eventType) { 1455 case 'pointerDown': 1456 return input.scanOnPenPress; 1457 case 'pointerUp': 1458 return input.scanOnPenRelease; 1459 } 1460 switch (this._penPointerState) { 1461 case 1: 1462 return input.scanOnPenHover; 1463 case 2: 1464 return input.scanOnPenMove; 1465 case 3: 1466 return input.scanOnPenReleaseHover; 1467 case 0: 1468 return false; 1469 } 1470 } 1471 1472 /** 1473 * @param {import('input').PointerType} pointerType 1474 * @param {import('input').PointerEventType} eventType 1475 * @param {PointerEvent|TouchEvent} event 1476 * @returns {?import('text-scanner').InputInfo} 1477 */ 1478 _getMatchingInputGroupFromEvent(pointerType, eventType, event) { 1479 const modifiers = getActiveModifiersAndButtons(event); 1480 const modifierKeys = getActiveModifiers(event); 1481 return this._getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys); 1482 } 1483 1484 /** 1485 * @param {import('input').PointerType} pointerType 1486 * @param {import('input').PointerEventType} eventType 1487 * @param {import('input').Modifier[]} modifiers 1488 * @param {import('input').ModifierKey[]} modifierKeys 1489 * @returns {?import('text-scanner').InputInfo} 1490 */ 1491 _getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys) { 1492 let fallbackIndex = -1; 1493 const modifiersSet = new Set(modifiers); 1494 for (let i = 0, ii = this._inputs.length; i < ii; ++i) { 1495 const input = this._inputs[i]; 1496 const {include, exclude, types} = input; 1497 if (!types.has(pointerType)) { continue; } 1498 if (this._setHasAll(modifiersSet, include) && (exclude.length === 0 || !this._setHasAll(modifiersSet, exclude))) { 1499 if (include.length > 0) { 1500 return this._createInputInfo(input, pointerType, eventType, false, modifiers, modifierKeys); 1501 } else if (fallbackIndex < 0) { 1502 fallbackIndex = i; 1503 } 1504 } 1505 } 1506 1507 return ( 1508 fallbackIndex >= 0 ? 1509 this._createInputInfo(this._inputs[fallbackIndex], pointerType, eventType, true, modifiers, modifierKeys) : 1510 null 1511 ); 1512 } 1513 1514 /** 1515 * @param {?import('text-scanner').InputConfig} input 1516 * @param {import('input').PointerType} pointerType 1517 * @param {import('input').PointerEventType} eventType 1518 * @param {boolean} passive 1519 * @param {import('input').Modifier[]} modifiers 1520 * @param {import('input').ModifierKey[]} modifierKeys 1521 * @param {import('text-scanner').InputInfoDetail?} [detail] 1522 * @returns {import('text-scanner').InputInfo} 1523 */ 1524 _createInputInfo(input, pointerType, eventType, passive, modifiers, modifierKeys, detail) { 1525 return {input, pointerType, eventType, passive, modifiers, modifierKeys, detail}; 1526 } 1527 1528 /** 1529 * @param {Set<string>} set 1530 * @param {string[]} values 1531 * @returns {boolean} 1532 */ 1533 _setHasAll(set, values) { 1534 for (const value of values) { 1535 if (!set.has(value)) { 1536 return false; 1537 } 1538 } 1539 return true; 1540 } 1541 1542 /** 1543 * @param {import('text-scanner').InputOptionsOuter} input 1544 * @returns {import('text-scanner').InputConfig} 1545 */ 1546 _convertInput(input) { 1547 const {options} = input; 1548 return { 1549 include: this._getInputArray(input.include), 1550 exclude: this._getInputArray(input.exclude), 1551 types: this._getInputTypeSet(input.types), 1552 searchTerms: this._getInputBoolean(options.searchTerms), 1553 searchKanji: this._getInputBoolean(options.searchKanji), 1554 scanOnTouchMove: this._getInputBoolean(options.scanOnTouchMove), 1555 scanOnTouchPress: this._getInputBoolean(options.scanOnTouchPress), 1556 scanOnTouchRelease: this._getInputBoolean(options.scanOnTouchRelease), 1557 scanOnTouchTap: this._getInputBoolean(options.scanOnTouchTap), 1558 scanOnPenMove: this._getInputBoolean(options.scanOnPenMove), 1559 scanOnPenHover: this._getInputBoolean(options.scanOnPenHover), 1560 scanOnPenReleaseHover: this._getInputBoolean(options.scanOnPenReleaseHover), 1561 scanOnPenPress: this._getInputBoolean(options.scanOnPenPress), 1562 scanOnPenRelease: this._getInputBoolean(options.scanOnPenRelease), 1563 preventTouchScrolling: this._getInputBoolean(options.preventTouchScrolling), 1564 preventPenScrolling: this._getInputBoolean(options.preventPenScrolling), 1565 minimumTouchTime: this._getInputNumber(options.minimumTouchTime), 1566 }; 1567 } 1568 1569 /** 1570 * @param {string} value 1571 * @returns {string[]} 1572 */ 1573 _getInputArray(value) { 1574 return ( 1575 typeof value === 'string' ? 1576 value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0) : 1577 [] 1578 ); 1579 } 1580 1581 /** 1582 * @param {{mouse: boolean, touch: boolean, pen: boolean}} details 1583 * @returns {Set<'mouse'|'touch'|'pen'>} 1584 */ 1585 _getInputTypeSet({mouse, touch, pen}) { 1586 const set = new Set(); 1587 if (mouse) { set.add('mouse'); } 1588 if (touch) { set.add('touch'); } 1589 if (pen) { set.add('pen'); } 1590 return set; 1591 } 1592 1593 /** 1594 * @param {unknown} value 1595 * @returns {boolean} 1596 */ 1597 _getInputBoolean(value) { 1598 return typeof value === 'boolean' && value; 1599 } 1600 1601 /** 1602 * @param {unknown} value 1603 * @returns {number} 1604 */ 1605 _getInputNumber(value) { 1606 return typeof value === 'number' ? value : -1; 1607 } 1608 1609 /** 1610 * @param {PointerEvent} e 1611 * @returns {string} 1612 */ 1613 _getPointerEventType(e) { 1614 // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events. 1615 const cachedPointerType = this._pointerIdTypeMap.get(e.pointerId); 1616 return (typeof cachedPointerType !== 'undefined' ? cachedPointerType : e.pointerType); 1617 } 1618 1619 /** 1620 * @param {import('text-source').TextSource} textSource 1621 * @param {?string} includeSelector 1622 * @param {?string} excludeSelector 1623 * @param {boolean} layoutAwareScan 1624 */ 1625 _constrainTextSource(textSource, includeSelector, excludeSelector, layoutAwareScan) { 1626 let length = textSource.text().length; 1627 1628 while (length > 0) { 1629 const nodes = textSource.getNodesInRange(); 1630 if ( 1631 (includeSelector !== null && !everyNodeMatchesSelector(nodes, includeSelector)) || 1632 (excludeSelector !== null && anyNodeMatchesSelector(nodes, excludeSelector)) 1633 ) { 1634 --length; 1635 textSource.setEndOffset(length, false, layoutAwareScan); 1636 } else { 1637 break; 1638 } 1639 } 1640 } 1641 1642 /** 1643 * @param {import('input').PointerType | undefined} pointerType 1644 * @returns {?string} 1645 */ 1646 _getExcludeSelectorForPointerType(pointerType) { 1647 if (pointerType === 'touch') { 1648 return this._excludeSelector ? `${this._excludeSelector},${this.touchEventExcludeSelector}` : this.touchEventExcludeSelector; 1649 } 1650 return this._excludeSelector; 1651 } 1652 1653 /** 1654 * @param {string} text 1655 * @returns {Promise<boolean>} 1656 */ 1657 async _isTextLookupWorthy(text) { 1658 try { 1659 return this._language !== null && text.length > 0 && await this._api.isTextLookupWorthy(text, this._language); 1660 } catch (e) { 1661 return false; 1662 } 1663 } 1664 1665 /** 1666 * @returns {import('text-scanner').SelectionRestoreInfo} 1667 */ 1668 _createSelectionRestoreInfo() { 1669 const ranges = []; 1670 const selection = window.getSelection(); 1671 if (selection !== null) { 1672 for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { 1673 const range = selection.getRangeAt(i); 1674 ranges.push(range.cloneRange()); 1675 } 1676 } 1677 return {ranges}; 1678 } 1679 1680 /** 1681 * @param {import('text-scanner').SelectionRestoreInfo} selectionRestoreInfo 1682 */ 1683 _restoreSelection(selectionRestoreInfo) { 1684 const {ranges} = selectionRestoreInfo; 1685 const selection = window.getSelection(); 1686 if (selection === null) { return; } 1687 selection.removeAllRanges(); 1688 for (const range of ranges) { 1689 try { 1690 selection.addRange(range); 1691 } catch (e) { 1692 // NOP 1693 } 1694 } 1695 } 1696 1697 /** 1698 * @param {import('text-scanner').ClearReason} reason 1699 */ 1700 _triggerClear(reason) { 1701 this.trigger('clear', {reason}); 1702 } 1703 1704 /** 1705 * @returns {boolean} 1706 */ 1707 _computeUserHasNotSelectedAnythingManually() { 1708 const selection = window.getSelection(); 1709 return selection === null || selection.isCollapsed; 1710 } 1711}