Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
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}