Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2017-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 {FrameEndpoint} from '../comm/frame-endpoint.js';
21import {extendApiMap, invokeApiMapHandler} from '../core/api-map.js';
22import {DynamicProperty} from '../core/dynamic-property.js';
23import {EventDispatcher} from '../core/event-dispatcher.js';
24import {EventListenerCollection} from '../core/event-listener-collection.js';
25import {ExtensionError} from '../core/extension-error.js';
26import {log} from '../core/log.js';
27import {safePerformance} from '../core/safe-performance.js';
28import {toError} from '../core/to-error.js';
29import {addScopeToCss, clone, deepEqual, promiseTimeout} from '../core/utilities.js';
30import {setProfile} from '../data/profiles-util.js';
31import {PopupMenu} from '../dom/popup-menu.js';
32import {querySelectorNotNull} from '../dom/query-selector.js';
33import {ScrollElement} from '../dom/scroll-element.js';
34import {TextSourceGenerator} from '../dom/text-source-generator.js';
35import {HotkeyHelpController} from '../input/hotkey-help-controller.js';
36import {TextScanner} from '../language/text-scanner.js';
37import {checkPopupPreviewURL} from '../pages/settings/popup-preview-controller.js';
38import {DisplayContentManager} from './display-content-manager.js';
39import {DisplayGenerator} from './display-generator.js';
40import {DisplayHistory} from './display-history.js';
41import {DisplayNotification} from './display-notification.js';
42import {ElementOverflowController} from './element-overflow-controller.js';
43import {OptionToggleHotkeyHandler} from './option-toggle-hotkey-handler.js';
44import {QueryParser} from './query-parser.js';
45
46/**
47 * @augments EventDispatcher<import('display').Events>
48 */
49export class Display extends EventDispatcher {
50 /**
51 * @param {import('../application.js').Application} application
52 * @param {import('display').DisplayPageType} pageType
53 * @param {import('../dom/document-focus-controller.js').DocumentFocusController} documentFocusController
54 * @param {import('../input/hotkey-handler.js').HotkeyHandler} hotkeyHandler
55 */
56 constructor(application, pageType, documentFocusController, hotkeyHandler) {
57 super();
58 /** @type {import('../application.js').Application} */
59 this._application = application;
60 /** @type {import('display').DisplayPageType} */
61 this._pageType = pageType;
62 /** @type {import('../dom/document-focus-controller.js').DocumentFocusController} */
63 this._documentFocusController = documentFocusController;
64 /** @type {import('../input/hotkey-handler.js').HotkeyHandler} */
65 this._hotkeyHandler = hotkeyHandler;
66 /** @type {HTMLElement} */
67 this._container = querySelectorNotNull(document, '#dictionary-entries');
68 /** @type {import('dictionary').DictionaryEntry[]} */
69 this._dictionaryEntries = [];
70 /** @type {HTMLElement[]} */
71 this._dictionaryEntryNodes = [];
72 /** @type {import('settings').OptionsContext} */
73 this._optionsContext = {depth: 0, url: window.location.href};
74 /** @type {?import('settings').ProfileOptions} */
75 this._options = null;
76 /** @type {number} */
77 this._index = 0;
78 /** @type {?HTMLStyleElement} */
79 this._styleNode = null;
80 /** @type {EventListenerCollection} */
81 this._eventListeners = new EventListenerCollection();
82 /** @type {?import('core').TokenObject} */
83 this._setContentToken = null;
84 /** @type {DisplayContentManager} */
85 this._contentManager = new DisplayContentManager(this);
86 /** @type {HotkeyHelpController} */
87 this._hotkeyHelpController = new HotkeyHelpController();
88 /** @type {DisplayGenerator} */
89 this._displayGenerator = new DisplayGenerator(this._contentManager, this._hotkeyHelpController);
90 /** @type {import('display').DirectApiMap} */
91 this._directApiMap = new Map();
92 /** @type {import('api-map').ApiMap<import('display').WindowApiSurface>} */ // import('display').WindowApiMap
93 this._windowApiMap = new Map();
94 /** @type {DisplayHistory} */
95 this._history = new DisplayHistory(true, false);
96 /** @type {boolean} */
97 this._historyChangeIgnore = false;
98 /** @type {boolean} */
99 this._historyHasChanged = false;
100 /** @type {?Element} */
101 this._aboveStickyHeader = document.querySelector('#above-sticky-header');
102 /** @type {?Element} */
103 this._searchHeader = document.querySelector('#sticky-search-header');
104 /** @type {import('display').PageType} */
105 this._contentType = 'clear';
106 /** @type {string} */
107 this._defaultTitle = document.title;
108 /** @type {number} */
109 this._titleMaxLength = 1000;
110 /** @type {string} */
111 this._query = '';
112 /** @type {string} */
113 this._fullQuery = '';
114 /** @type {number} */
115 this._queryOffset = 0;
116 /** @type {HTMLElement} */
117 this._progressIndicator = querySelectorNotNull(document, '#progress-indicator');
118 /** @type {?import('core').Timeout} */
119 this._progressIndicatorTimer = null;
120 /** @type {DynamicProperty<boolean>} */
121 this._progressIndicatorVisible = new DynamicProperty(false);
122 /** @type {boolean} */
123 this._queryParserVisible = false;
124 /** @type {?boolean} */
125 this._queryParserVisibleOverride = null;
126 /** @type {HTMLElement} */
127 this._queryParserContainer = querySelectorNotNull(document, '#query-parser-container');
128 /** @type {TextSourceGenerator} */
129 this._textSourceGenerator = new TextSourceGenerator();
130 /** @type {QueryParser} */
131 this._queryParser = new QueryParser(application.api, this._textSourceGenerator, this._getSearchContext.bind(this));
132 /** @type {HTMLElement} */
133 this._contentScrollElement = querySelectorNotNull(document, '#content-scroll');
134 /** @type {HTMLElement} */
135 this._contentScrollBodyElement = querySelectorNotNull(document, '#content-body');
136 /** @type {ScrollElement} */
137 this._windowScroll = new ScrollElement(this._contentScrollElement);
138 /** @type {?HTMLButtonElement} */
139 this._closeButton = document.querySelector('#close-button');
140 /** @type {?HTMLButtonElement} */
141 this._navigationPreviousButton = document.querySelector('#navigate-previous-button');
142 /** @type {?HTMLButtonElement} */
143 this._navigationNextButton = document.querySelector('#navigate-next-button');
144 /** @type {?import('../app/frontend.js').Frontend} */
145 this._frontend = null;
146 /** @type {?Promise<void>} */
147 this._frontendSetupPromise = null;
148 /** @type {number} */
149 this._depth = 0;
150 /** @type {?string} */
151 this._parentPopupId = null;
152 /** @type {?number} */
153 this._parentFrameId = null;
154 /** @type {?number} */
155 this._contentOriginTabId = application.tabId;
156 /** @type {?number} */
157 this._contentOriginFrameId = application.frameId;
158 /** @type {boolean} */
159 this._childrenSupported = true;
160 /** @type {?FrameEndpoint} */
161 this._frameEndpoint = (pageType === 'popup' ? new FrameEndpoint(this._application.api) : null);
162 /** @type {?import('environment').Browser} */
163 this._browser = null;
164 /** @type {?import('environment').OperatingSystem} */
165 this._platform = null;
166 /** @type {?HTMLTextAreaElement} */
167 this._copyTextarea = null;
168 /** @type {?TextScanner} */
169 this._contentTextScanner = null;
170 /** @type {?import('./display-notification.js').DisplayNotification} */
171 this._tagNotification = null;
172 /** @type {?import('./display-notification.js').DisplayNotification} */
173 this._inflectionNotification = null;
174 /** @type {HTMLElement} */
175 this._footerNotificationContainer = querySelectorNotNull(document, '#content-footer');
176 /** @type {OptionToggleHotkeyHandler} */
177 this._optionToggleHotkeyHandler = new OptionToggleHotkeyHandler(this);
178 /** @type {ElementOverflowController} */
179 this._elementOverflowController = new ElementOverflowController(this);
180 /** @type {boolean} */
181 this._frameVisible = (pageType === 'search');
182 /** @type {HTMLElement} */
183 this._menuContainer = querySelectorNotNull(document, '#popup-menus');
184 /** @type {(event: MouseEvent) => void} */
185 this._onEntryClickBind = this._onEntryClick.bind(this);
186 /** @type {(event: MouseEvent) => void} */
187 this._onKanjiLookupBind = this._onKanjiLookup.bind(this);
188 /** @type {(event: MouseEvent) => void} */
189 this._onDebugLogClickBind = this._onDebugLogClick.bind(this);
190 /** @type {(event: MouseEvent) => void} */
191 this._onTagClickBind = this._onTagClick.bind(this);
192 /** @type {(event: MouseEvent) => void} */
193 this._onInflectionClickBind = this._onInflectionClick.bind(this);
194 /** @type {(event: MouseEvent) => void} */
195 this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this);
196 /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */
197 this._onMenuButtonMenuCloseBind = this._onMenuButtonMenuClose.bind(this);
198 /** @type {ThemeController} */
199 this._themeController = new ThemeController(document.documentElement);
200 /** @type {import('language').LanguageSummary[]} */
201 this._languageSummaries = [];
202 /** @type {import('dictionary-importer').Summary[]} */
203 this._dictionaryInfo = [];
204
205 /* eslint-disable @stylistic/no-multi-spaces */
206 this._hotkeyHandler.registerActions([
207 ['close', () => { this._onHotkeyClose(); }],
208 ['nextEntry', this._onHotkeyActionMoveRelative.bind(this, 1)],
209 ['previousEntry', this._onHotkeyActionMoveRelative.bind(this, -1)],
210 ['lastEntry', () => { this._focusEntry(this._dictionaryEntries.length - 1, 0, true); }],
211 ['firstEntry', () => { this._focusEntry(0, 0, true); }],
212 ['historyBackward', () => { this._sourceTermView(); }],
213 ['historyForward', () => { this._nextTermView(); }],
214 ['profilePrevious', async () => { await setProfile(-1, this._application); }],
215 ['profileNext', async () => { await setProfile(1, this._application); }],
216 ['copyHostSelection', () => this._copyHostSelection()],
217 ['nextEntryDifferentDictionary', () => { this._focusEntryWithDifferentDictionary(1, true); }],
218 ['previousEntryDifferentDictionary', () => { this._focusEntryWithDifferentDictionary(-1, true); }],
219 ]);
220 this.registerDirectMessageHandlers([
221 ['displaySetOptionsContext', this._onMessageSetOptionsContext.bind(this)],
222 ['displaySetContent', this._onMessageSetContent.bind(this)],
223 ['displaySetCustomCss', this._onMessageSetCustomCss.bind(this)],
224 ['displaySetContentScale', this._onMessageSetContentScale.bind(this)],
225 ['displayConfigure', this._onMessageConfigure.bind(this)],
226 ['displayVisibilityChanged', this._onMessageVisibilityChanged.bind(this)],
227 ]);
228 this.registerWindowMessageHandlers([
229 ['displayExtensionUnloaded', this._onMessageExtensionUnloaded.bind(this)],
230 ]);
231 /* eslint-enable @stylistic/no-multi-spaces */
232 }
233
234 /** @type {import('../application.js').Application} */
235 get application() {
236 return this._application;
237 }
238
239 /** @type {DisplayGenerator} */
240 get displayGenerator() {
241 return this._displayGenerator;
242 }
243
244 /** @type {boolean} */
245 get queryParserVisible() {
246 return this._queryParserVisible;
247 }
248
249 set queryParserVisible(value) {
250 this._queryParserVisible = value;
251 this._updateQueryParser();
252 }
253
254 /** @type {number} */
255 get depth() {
256 return this._depth;
257 }
258
259 /** @type {import('../input/hotkey-handler.js').HotkeyHandler} */
260 get hotkeyHandler() {
261 return this._hotkeyHandler;
262 }
263
264 /** @type {import('dictionary').DictionaryEntry[]} */
265 get dictionaryEntries() {
266 return this._dictionaryEntries;
267 }
268
269 /** @type {HTMLElement[]} */
270 get dictionaryEntryNodes() {
271 return this._dictionaryEntryNodes;
272 }
273
274 /** @type {DynamicProperty<boolean>} */
275 get progressIndicatorVisible() {
276 return this._progressIndicatorVisible;
277 }
278
279 /** @type {?string} */
280 get parentPopupId() {
281 return this._parentPopupId;
282 }
283
284 /** @type {number} */
285 get selectedIndex() {
286 return this._index;
287 }
288
289 /** @type {DisplayHistory} */
290 get history() {
291 return this._history;
292 }
293
294 /** @type {string} */
295 get query() {
296 return this._query;
297 }
298
299 /** @type {string} */
300 get fullQuery() {
301 return this._fullQuery;
302 }
303
304 /** @type {number} */
305 get queryOffset() {
306 return this._queryOffset;
307 }
308
309 /** @type {boolean} */
310 get frameVisible() {
311 return this._frameVisible;
312 }
313
314 /** */
315 async prepare() {
316 // Theme
317 this._themeController.prepare();
318
319 // State setup
320 const {documentElement} = document;
321 const {browser, platform} = await this._application.api.getEnvironmentInfo();
322 this._browser = browser;
323 this._platform = platform.os;
324
325 if (documentElement !== null) {
326 documentElement.dataset.browser = browser;
327 documentElement.dataset.platform = platform.os;
328 }
329
330 this._languageSummaries = await this._application.api.getLanguageSummaries();
331
332 this._dictionaryInfo = await this._application.api.getDictionaryInfo();
333
334 // Prepare
335 await this._hotkeyHelpController.prepare(this._application.api);
336 await this._displayGenerator.prepare();
337 this._queryParser.prepare();
338 this._history.prepare();
339 this._optionToggleHotkeyHandler.prepare();
340
341 // Event setup
342 this._history.on('stateChanged', this._onStateChanged.bind(this));
343 this._queryParser.on('searched', this._onQueryParserSearch.bind(this));
344 this._progressIndicatorVisible.on('change', this._onProgressIndicatorVisibleChanged.bind(this));
345 this._application.on('extensionUnloaded', this._onExtensionUnloaded.bind(this));
346 this._application.crossFrame.registerHandlers([
347 ['displayPopupMessage1', this._onDisplayPopupMessage1.bind(this)],
348 ['displayPopupMessage2', this._onDisplayPopupMessage2.bind(this)],
349 ]);
350 window.addEventListener('message', this._onWindowMessage.bind(this), false);
351
352 if (this._pageType === 'popup' && documentElement !== null) {
353 documentElement.addEventListener('mouseup', this._onDocumentElementMouseUp.bind(this), false);
354 documentElement.addEventListener('click', this._onDocumentElementClick.bind(this), false);
355 documentElement.addEventListener('auxclick', this._onDocumentElementClick.bind(this), false);
356 }
357
358 document.addEventListener('wheel', this._onWheel.bind(this), {passive: false});
359 if (this._contentScrollElement !== null) {
360 this._contentScrollElement.addEventListener('touchstart', this._onTouchStart.bind(this), {passive: true});
361 this._contentScrollElement.addEventListener('touchmove', this._onTouchMove.bind(this), {passive: false});
362 }
363 if (this._closeButton !== null) {
364 this._closeButton.addEventListener('click', this._onCloseButtonClick.bind(this), false);
365 }
366 if (this._navigationPreviousButton !== null) {
367 this._navigationPreviousButton.addEventListener('click', this._onSourceTermView.bind(this), false);
368 }
369 if (this._navigationNextButton !== null) {
370 this._navigationNextButton.addEventListener('click', this._onNextTermView.bind(this), false);
371 }
372 }
373
374 /**
375 * @returns {import('extension').ContentOrigin}
376 */
377 getContentOrigin() {
378 return {
379 tabId: this._contentOriginTabId,
380 frameId: this._contentOriginFrameId,
381 };
382 }
383
384 /** */
385 initializeState() {
386 void this._onStateChanged();
387 if (this._frameEndpoint !== null) {
388 this._frameEndpoint.signal();
389 }
390 }
391
392 /**
393 * @param {Element} element
394 */
395 scrollUpToElementTop(element) {
396 const top = this._getElementTop(element);
397 if (this._windowScroll.y > top) {
398 this._windowScroll.toY(top);
399 }
400 }
401
402 /**
403 * @param {{clearable?: boolean, useBrowserHistory?: boolean}} details
404 */
405 setHistorySettings({clearable, useBrowserHistory}) {
406 if (typeof clearable !== 'undefined') {
407 this._history.clearable = clearable;
408 }
409 if (typeof useBrowserHistory !== 'undefined') {
410 this._history.useBrowserHistory = useBrowserHistory;
411 }
412 }
413
414 /**
415 * @param {Error} error
416 */
417 onError(error) {
418 if (this._application.webExtension.unloaded) { return; }
419 log.error(error);
420 }
421
422 /**
423 * @returns {?import('settings').ProfileOptions}
424 */
425 getOptions() {
426 return this._options;
427 }
428
429 /**
430 * @returns {import('language').LanguageSummary}
431 * @throws {Error}
432 */
433 getLanguageSummary() {
434 if (this._options === null) { throw new Error('Options is null'); }
435 const language = this._options.general.language;
436 return /** @type {import('language').LanguageSummary} */ (this._languageSummaries.find(({iso}) => iso === language));
437 }
438
439 /**
440 * @returns {import('settings').OptionsContext}
441 */
442 getOptionsContext() {
443 return this._optionsContext;
444 }
445
446 /**
447 * @param {import('settings').OptionsContext} optionsContext
448 */
449 async setOptionsContext(optionsContext) {
450 this._optionsContext = optionsContext;
451 await this.updateOptions();
452 }
453
454 /** */
455 async updateOptions() {
456 const options = await this._application.api.optionsGet(this.getOptionsContext());
457 const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options;
458 this._options = options;
459
460 this._updateHotkeys(options);
461 this._updateDocumentOptions(options);
462 this._setTheme(options);
463 this._setStickyHeader(options);
464 this._hotkeyHelpController.setOptions(options);
465 this._displayGenerator.updateHotkeys();
466 this._displayGenerator.updateLanguage(options.general.language);
467 this._hotkeyHelpController.setupNode(document.documentElement);
468 this._elementOverflowController.setOptions(options);
469
470 this._queryParser.setOptions({
471 selectedParser: options.parsing.selectedParser,
472 termSpacing: options.parsing.termSpacing,
473 readingMode: options.parsing.readingMode,
474 useInternalParser: options.parsing.enableScanningParser,
475 useMecabParser: options.parsing.enableMecabParser,
476 language: options.general.language,
477 scanning: {
478 inputs: scanningOptions.inputs,
479 deepContentScan: scanningOptions.deepDomScan,
480 normalizeCssZoom: scanningOptions.normalizeCssZoom,
481 selectText: scanningOptions.selectText,
482 delay: scanningOptions.delay,
483 scanLength: scanningOptions.length,
484 layoutAwareScan: scanningOptions.layoutAwareScan,
485 preventMiddleMouseOnPage: scanningOptions.preventMiddleMouse.onSearchQuery,
486 preventMiddleMouseOnTextHover: scanningOptions.preventMiddleMouse.onTextHover,
487 preventBackForwardOnPage: scanningOptions.preventBackForward.onSearchQuery,
488 preventBackForwardOnTextHover: scanningOptions.preventBackForward.onTextHover,
489 matchTypePrefix: false,
490 sentenceParsingOptions,
491 scanWithoutMousemove: scanningOptions.scanWithoutMousemove,
492 scanResolution: scanningOptions.scanResolution,
493 },
494 });
495
496 void this._updateNestedFrontend(options);
497 this._updateContentTextScanner(options);
498
499 this.trigger('optionsUpdated', {options});
500 }
501
502 /**
503 * Updates the content of the display.
504 * @param {import('display').ContentDetails} details Information about the content to show.
505 */
506 setContent(details) {
507 const {focus, params, state, content} = details;
508 const historyMode = this._historyHasChanged ? details.historyMode : 'clear';
509
510 if (focus) {
511 window.focus();
512 }
513
514 const urlSearchParams = new URLSearchParams();
515 for (const [key, value] of Object.entries(params)) {
516 if (typeof value !== 'string') { continue; }
517 urlSearchParams.append(key, value);
518 }
519 const url = `${location.protocol}//${location.host}${location.pathname}?${urlSearchParams.toString()}`;
520
521 switch (historyMode) {
522 case 'clear':
523 this._history.clear();
524 this._history.replaceState(state, content, url);
525 break;
526 case 'overwrite':
527 this._history.replaceState(state, content, url);
528 break;
529 case 'new':
530 this._updateHistoryState();
531 this._history.pushState(state, content, url);
532 break;
533 }
534
535 if (this._options) {
536 this._setTheme(this._options);
537 }
538 }
539
540 /**
541 * @param {string} css
542 */
543 setCustomCss(css) {
544 if (this._styleNode === null) {
545 if (css.length === 0) { return; }
546 this._styleNode = document.createElement('style');
547 }
548
549 this._styleNode.textContent = css;
550
551 const parent = document.head;
552 if (this._styleNode.parentNode !== parent) {
553 parent.appendChild(this._styleNode);
554 }
555 }
556
557 /**
558 * @param {string} fontFamily
559 * @param {number} fontSize
560 * @param {string} lineHeight
561 */
562 setFontOptions(fontFamily, fontSize, lineHeight) {
563 // Setting these directly rather than using the existing CSS variables
564 // minimizes problems and ensures everything scales correctly
565 document.documentElement.style.fontFamily = fontFamily;
566 document.documentElement.style.fontSize = `${fontSize}px`;
567 document.documentElement.style.lineHeight = lineHeight;
568 }
569
570 /**
571 * @param {import('display').DirectApiMapInit} handlers
572 */
573 registerDirectMessageHandlers(handlers) {
574 extendApiMap(this._directApiMap, handlers);
575 }
576
577 /**
578 * @param {import('display').WindowApiMapInit} handlers
579 */
580 registerWindowMessageHandlers(handlers) {
581 extendApiMap(this._windowApiMap, handlers);
582 }
583
584 /** */
585 close() {
586 switch (this._pageType) {
587 case 'popup':
588 void this.invokeContentOrigin('frontendClosePopup', void 0);
589 break;
590 case 'search':
591 void this._closeTab();
592 break;
593 }
594 }
595
596 /**
597 * @param {HTMLElement} element
598 */
599 blurElement(element) {
600 this._documentFocusController.blurElement(element);
601 }
602
603 /**
604 * @param {boolean} updateOptionsContext
605 */
606 searchLast(updateOptionsContext) {
607 const type = this._contentType;
608 if (type === 'clear') { return; }
609 const query = this._query;
610 const {state} = this._history;
611 const hasState = typeof state === 'object' && state !== null;
612 /** @type {import('display').HistoryState} */
613 const newState = (
614 hasState ?
615 clone(state) :
616 {
617 focusEntry: 0,
618 optionsContext: void 0,
619 url: window.location.href,
620 sentence: {text: query, offset: 0},
621 documentTitle: document.title,
622 }
623 );
624 if (!hasState || updateOptionsContext) {
625 newState.optionsContext = clone(this._optionsContext);
626 }
627 /** @type {import('display').ContentDetails} */
628 const details = {
629 focus: false,
630 historyMode: 'clear',
631 params: this._createSearchParams(type, query, false, this._queryOffset),
632 state: newState,
633 content: {
634 contentOrigin: this.getContentOrigin(),
635 },
636 };
637 this.setContent(details);
638 }
639
640 /**
641 * @template {import('cross-frame-api').ApiNames} TName
642 * @param {TName} action
643 * @param {import('cross-frame-api').ApiParams<TName>} params
644 * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
645 */
646 async invokeContentOrigin(action, params) {
647 if (this._contentOriginTabId === this._application.tabId && this._contentOriginFrameId === this._application.frameId) {
648 throw new Error('Content origin is same page');
649 }
650 if (this._contentOriginTabId === null || this._contentOriginFrameId === null) {
651 throw new Error('No content origin is assigned');
652 }
653 return await this._application.crossFrame.invokeTab(this._contentOriginTabId, this._contentOriginFrameId, action, params);
654 }
655
656 /**
657 * @template {import('cross-frame-api').ApiNames} TName
658 * @param {TName} action
659 * @param {import('cross-frame-api').ApiParams<TName>} params
660 * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
661 */
662 async invokeParentFrame(action, params) {
663 const {frameId} = this._application;
664 if (frameId === null || this._parentFrameId === null || this._parentFrameId === frameId) {
665 throw new Error('Invalid parent frame');
666 }
667 return await this._application.crossFrame.invoke(this._parentFrameId, action, params);
668 }
669
670 /**
671 * @param {Element} element
672 * @returns {number}
673 */
674 getElementDictionaryEntryIndex(element) {
675 const node = /** @type {?HTMLElement} */ (element.closest('.entry'));
676 if (node === null) { return -1; }
677 const {index} = node.dataset;
678 if (typeof index !== 'string') { return -1; }
679 const indexNumber = Number.parseInt(index, 10);
680 return Number.isFinite(indexNumber) ? indexNumber : -1;
681 }
682
683 /**
684 * Creates a new notification.
685 * @param {boolean} scannable Whether or not the notification should permit its content to be scanned.
686 * @returns {DisplayNotification} A new notification instance.
687 */
688 createNotification(scannable) {
689 const node = this._displayGenerator.createEmptyFooterNotification();
690 if (scannable) {
691 node.classList.add('click-scannable');
692 }
693 return new DisplayNotification(this._footerNotificationContainer, node);
694 }
695
696 // Message handlers
697
698 /** @type {import('cross-frame-api').ApiHandler<'displayPopupMessage1'>} */
699 async _onDisplayPopupMessage1(message) {
700 /** @type {import('display').DirectApiMessageAny} */
701 const messageInner = this._authenticateMessageData(message);
702 return await this._onDisplayPopupMessage2(messageInner);
703 }
704
705 /** @type {import('cross-frame-api').ApiHandler<'displayPopupMessage2'>} */
706 _onDisplayPopupMessage2(message) {
707 return new Promise((resolve, reject) => {
708 const {action, params} = message;
709 invokeApiMapHandler(
710 this._directApiMap,
711 action,
712 params,
713 [],
714 (result) => {
715 const {error} = result;
716 if (typeof error !== 'undefined') {
717 reject(ExtensionError.deserialize(error));
718 } else {
719 resolve(result.result);
720 }
721 },
722 () => {
723 reject(new Error(`Invalid action: ${action}`));
724 },
725 );
726 });
727 }
728
729 /**
730 * @param {MessageEvent<import('display').WindowApiFrameClientMessageAny>} details
731 */
732 _onWindowMessage({data}) {
733 /** @type {import('display').WindowApiMessageAny} */
734 let data2;
735 try {
736 data2 = this._authenticateMessageData(data);
737 } catch (e) {
738 return;
739 }
740
741 try {
742 const {action, params} = data2;
743 const callback = () => {}; // NOP
744 invokeApiMapHandler(this._windowApiMap, action, params, [], callback);
745 } catch (e) {
746 // NOP
747 }
748 }
749
750 /** @type {import('display').DirectApiHandler<'displaySetOptionsContext'>} */
751 async _onMessageSetOptionsContext({optionsContext}) {
752 await this.setOptionsContext(optionsContext);
753 this.searchLast(true);
754 }
755
756 /** @type {import('display').DirectApiHandler<'displaySetContent'>} */
757 _onMessageSetContent({details}) {
758 safePerformance.mark('invokeDisplaySetContent:end');
759 this.setContent(details);
760 }
761
762 /** @type {import('display').DirectApiHandler<'displaySetCustomCss'>} */
763 _onMessageSetCustomCss({css}) {
764 this.setCustomCss(css);
765 }
766
767 /** @type {import('display').DirectApiHandler<'displaySetContentScale'>} */
768 _onMessageSetContentScale({scale}) {
769 this._setContentScale(scale);
770 }
771
772 /** @type {import('display').DirectApiHandler<'displayConfigure'>} */
773 async _onMessageConfigure({depth, parentPopupId, parentFrameId, childrenSupported, scale, optionsContext}) {
774 this._depth = depth;
775 this._parentPopupId = parentPopupId;
776 this._parentFrameId = parentFrameId;
777 this._childrenSupported = childrenSupported;
778 this._setContentScale(scale);
779 await this.setOptionsContext(optionsContext);
780 }
781
782 /** @type {import('display').DirectApiHandler<'displayVisibilityChanged'>} */
783 _onMessageVisibilityChanged({value}) {
784 this._frameVisible = value;
785 this.trigger('frameVisibilityChange', {value});
786 }
787
788 /** @type {import('display').WindowApiHandler<'displayExtensionUnloaded'>} */
789 _onMessageExtensionUnloaded() {
790 this._application.webExtension.triggerUnloaded();
791 }
792
793 // Private
794
795 /**
796 * @template [T=unknown]
797 * @param {import('frame-client').Message<unknown>} message
798 * @returns {T}
799 * @throws {Error}
800 */
801 _authenticateMessageData(message) {
802 if (this._frameEndpoint !== null && !this._frameEndpoint.authenticate(message)) {
803 throw new Error('Invalid authentication');
804 }
805 return /** @type {import('frame-client').Message<T>} */ (message).data;
806 }
807
808 /** */
809 async _onStateChanged() {
810 if (this._historyChangeIgnore) { return; }
811
812 safePerformance.mark('display:_onStateChanged:start');
813
814 /** @type {?import('core').TokenObject} */
815 const token = {}; // Unique identifier token
816 this._setContentToken = token;
817 try {
818 // Clear
819 safePerformance.mark('display:_onStateChanged:clear:start');
820 this._closePopups();
821 this._closeAllPopupMenus();
822 this._eventListeners.removeAllEventListeners();
823 this._contentManager.unloadAll();
824 this._hideTagNotification(false);
825 this._hideInflectionNotification(false);
826 this._triggerContentClear();
827 this._dictionaryEntries = [];
828 this._dictionaryEntryNodes = [];
829 this._elementOverflowController.clearElements();
830 safePerformance.mark('display:_onStateChanged:clear:end');
831 safePerformance.measure('display:_onStateChanged:clear', 'display:_onStateChanged:clear:start', 'display:_onStateChanged:clear:end');
832
833 // Prepare
834 safePerformance.mark('display:_onStateChanged:prepare:start');
835 const urlSearchParams = new URLSearchParams(location.search);
836 let type = urlSearchParams.get('type');
837 if (type === null && urlSearchParams.get('query') !== null) { type = 'terms'; }
838
839 const fullVisible = urlSearchParams.get('full-visible');
840 this._queryParserVisibleOverride = (fullVisible === null ? null : (fullVisible !== 'false'));
841
842 this._historyHasChanged = true;
843 safePerformance.mark('display:_onStateChanged:prepare:end');
844 safePerformance.measure('display:_onStateChanged:prepare', 'display:_onStateChanged:prepare:start', 'display:_onStateChanged:prepare:end');
845
846 safePerformance.mark('display:_onStateChanged:setContent:start');
847 // Set content
848 switch (type) {
849 case 'terms':
850 case 'kanji':
851 this._contentType = type;
852 await this._setContentTermsOrKanji(type, urlSearchParams, token);
853 break;
854 case 'unloaded':
855 this._contentType = type;
856 this._setContentExtensionUnloaded();
857 break;
858 default:
859 this._contentType = 'clear';
860 this._clearContent();
861 break;
862 }
863 safePerformance.mark('display:_onStateChanged:setContent:end');
864 safePerformance.measure('display:_onStateChanged:setContent', 'display:_onStateChanged:setContent:start', 'display:_onStateChanged:setContent:end');
865 } catch (e) {
866 this.onError(toError(e));
867 }
868 safePerformance.mark('display:_onStateChanged:end');
869 safePerformance.measure('display:_onStateChanged', 'display:_onStateChanged:start', 'display:_onStateChanged:end');
870 }
871
872 /**
873 * @param {import('query-parser').EventArgument<'searched'>} details
874 */
875 _onQueryParserSearch({type, dictionaryEntries, sentence, inputInfo: {eventType}, textSource, optionsContext, sentenceOffset}) {
876 const query = textSource.text();
877 const historyState = this._history.state;
878 const historyMode = (
879 eventType === 'click' ||
880 !(typeof historyState === 'object' && historyState !== null) ||
881 historyState.cause !== 'queryParser' ?
882 'new' :
883 'overwrite'
884 );
885 /** @type {import('display').ContentDetails} */
886 const details = {
887 focus: false,
888 historyMode,
889 params: this._createSearchParams(type, query, false, sentenceOffset),
890 state: {
891 sentence,
892 optionsContext,
893 cause: 'queryParser',
894 },
895 content: {
896 dictionaryEntries,
897 contentOrigin: this.getContentOrigin(),
898 },
899 };
900 this.setContent(details);
901 }
902
903 /** */
904 _onExtensionUnloaded() {
905 const type = 'unloaded';
906 if (this._contentType === type) { return; }
907 const {tabId, frameId} = this._application;
908 /** @type {import('display').ContentDetails} */
909 const details = {
910 focus: false,
911 historyMode: 'clear',
912 params: {type},
913 state: {},
914 content: {
915 contentOrigin: {tabId, frameId},
916 },
917 };
918 this.setContent(details);
919 }
920
921 /**
922 * @param {MouseEvent} e
923 */
924 _onCloseButtonClick(e) {
925 e.preventDefault();
926 this.close();
927 }
928
929 /**
930 * @param {MouseEvent} e
931 */
932 _onSourceTermView(e) {
933 e.preventDefault();
934 this._sourceTermView();
935 }
936
937 /**
938 * @param {MouseEvent} e
939 */
940 _onNextTermView(e) {
941 e.preventDefault();
942 this._nextTermView();
943 }
944
945 /**
946 * @param {import('dynamic-property').EventArgument<boolean, 'change'>} details
947 */
948 _onProgressIndicatorVisibleChanged({value}) {
949 if (this._progressIndicatorTimer !== null) {
950 clearTimeout(this._progressIndicatorTimer);
951 this._progressIndicatorTimer = null;
952 }
953
954 if (value) {
955 this._progressIndicator.hidden = false;
956 getComputedStyle(this._progressIndicator).getPropertyValue('display'); // Force update of CSS display property, allowing animation
957 this._progressIndicator.dataset.active = 'true';
958 } else {
959 this._progressIndicator.dataset.active = 'false';
960 this._progressIndicatorTimer = setTimeout(() => {
961 this._progressIndicator.hidden = true;
962 this._progressIndicatorTimer = null;
963 }, 250);
964 }
965 }
966
967 /**
968 * @param {MouseEvent} e
969 */
970 async _onKanjiLookup(e) {
971 try {
972 e.preventDefault();
973 const {state} = this._history;
974 if (!(typeof state === 'object' && state !== null)) { return; }
975
976 let {sentence, url, documentTitle} = state;
977 if (typeof url !== 'string') { url = window.location.href; }
978 if (typeof documentTitle !== 'string') { documentTitle = document.title; }
979 const optionsContext = this.getOptionsContext();
980 const element = /** @type {Element} */ (e.currentTarget);
981 let query = element.textContent;
982 if (query === null) { query = ''; }
983 const dictionaryEntries = await this._application.api.kanjiFind(query, optionsContext);
984 /** @type {import('display').ContentDetails} */
985 const details = {
986 focus: false,
987 historyMode: 'new',
988 params: this._createSearchParams('kanji', query, false, null),
989 state: {
990 focusEntry: 0,
991 optionsContext,
992 url,
993 sentence,
994 documentTitle,
995 },
996 content: {
997 dictionaryEntries,
998 contentOrigin: this.getContentOrigin(),
999 },
1000 };
1001 this.setContent(details);
1002 } catch (error) {
1003 this.onError(toError(error));
1004 }
1005 }
1006
1007 /**
1008 * @param {TouchEvent} e
1009 */
1010 _onTouchStart(e) {
1011 const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning;
1012 if (!scanningOptions.reducedMotionScrolling || e.touches.length !== 1) {
1013 return;
1014 }
1015
1016 const start = e.touches[0].clientY;
1017 /**
1018 * @param {TouchEvent} endEvent
1019 */
1020 const onTouchEnd = (endEvent) => {
1021 this._contentScrollElement.removeEventListener('touchend', onTouchEnd);
1022
1023 const end = endEvent.changedTouches[0].clientY;
1024 const delta = start - end;
1025 const threshold = scanningOptions.reducedMotionScrollingSwipeThreshold;
1026
1027 if (delta > threshold) {
1028 this._scrollByPopupHeight(1, scanningOptions.reducedMotionScrollingScale);
1029 } else if (delta < -threshold) {
1030 this._scrollByPopupHeight(-1, scanningOptions.reducedMotionScrollingScale);
1031 }
1032 };
1033
1034 this._contentScrollElement.addEventListener('touchend', onTouchEnd, {passive: true});
1035 }
1036
1037 /**
1038 * @param {TouchEvent} e
1039 */
1040 _onTouchMove = (e) => {
1041 const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning;
1042 if (scanningOptions.reducedMotionScrolling && e.cancelable) {
1043 e.preventDefault();
1044 }
1045 };
1046
1047 /**
1048 * @param {WheelEvent} e
1049 */
1050 _onWheel(e) {
1051 const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning;
1052 if (e.altKey) {
1053 if (e.deltaY !== 0) {
1054 this._focusEntry(this._index + (e.deltaY > 0 ? 1 : -1), 0, true);
1055 e.preventDefault();
1056 }
1057 } else if (e.shiftKey) {
1058 this._onHistoryWheel(e);
1059 } else if (scanningOptions.reducedMotionScrolling) {
1060 this._scrollByPopupHeight(e.deltaY > 0 ? 1 : -1, scanningOptions.reducedMotionScrollingScale);
1061 e.preventDefault();
1062 }
1063 }
1064
1065 /**
1066 * @param {WheelEvent} e
1067 */
1068 _onHistoryWheel(e) {
1069 if (e.altKey) { return; }
1070 const delta = -e.deltaX || e.deltaY;
1071 if (delta > 0) {
1072 this._sourceTermView();
1073 e.preventDefault();
1074 e.stopPropagation();
1075 } else if (delta < 0) {
1076 this._nextTermView();
1077 e.preventDefault();
1078 e.stopPropagation();
1079 }
1080 }
1081
1082 /**
1083 * @param {MouseEvent} e
1084 */
1085 _onDebugLogClick(e) {
1086 const link = /** @type {HTMLElement} */ (e.currentTarget);
1087 const index = this.getElementDictionaryEntryIndex(link);
1088 void this._logDictionaryEntryData(index);
1089 }
1090
1091 /**
1092 * @param {MouseEvent} e
1093 */
1094 _onDocumentElementMouseUp(e) {
1095 switch (e.button) {
1096 case 3: // Back
1097 if (this._history.hasPrevious()) {
1098 e.preventDefault();
1099 }
1100 break;
1101 case 4: // Forward
1102 if (this._history.hasNext()) {
1103 e.preventDefault();
1104 }
1105 break;
1106 }
1107 }
1108
1109 /**
1110 * @param {MouseEvent} e
1111 */
1112 _onDocumentElementClick(e) {
1113 const enableBackForwardActions = this._options ? !(this._options.scanning.preventBackForward.onPopupPages) : true;
1114 switch (e.button) {
1115 case 3: // Back
1116 if (enableBackForwardActions && this._history.hasPrevious()) {
1117 e.preventDefault();
1118 this._history.back();
1119 }
1120 break;
1121 case 4: // Forward
1122 if (enableBackForwardActions && this._history.hasNext()) {
1123 e.preventDefault();
1124 this._history.forward();
1125 }
1126 break;
1127 }
1128 }
1129
1130 /**
1131 * @param {MouseEvent} e
1132 */
1133 _onEntryClick(e) {
1134 if (e.button !== 0) { return; }
1135 const node = /** @type {HTMLElement} */ (e.currentTarget);
1136 const {index} = node.dataset;
1137 if (typeof index !== 'string') { return; }
1138 const indexNumber = Number.parseInt(index, 10);
1139 if (!Number.isFinite(indexNumber)) { return; }
1140 this._entrySetCurrent(indexNumber);
1141 }
1142
1143 /**
1144 * @param {MouseEvent} e
1145 */
1146 _onTagClick(e) {
1147 const node = /** @type {HTMLElement} */ (e.currentTarget);
1148 this._showTagNotification(node);
1149 }
1150
1151 /**
1152 * @param {MouseEvent} e
1153 */
1154 _onInflectionClick(e) {
1155 const node = /** @type {HTMLElement} */ (e.currentTarget);
1156 this._showInflectionNotification(node);
1157 }
1158
1159 /**
1160 * @param {MouseEvent} e
1161 */
1162 _onMenuButtonClick(e) {
1163 const node = /** @type {HTMLElement} */ (e.currentTarget);
1164
1165 const menuContainerNode = /** @type {HTMLElement} */ (this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu'));
1166 /** @type {HTMLElement} */
1167 const menuBodyNode = querySelectorNotNull(menuContainerNode, '.popup-menu-body');
1168
1169 /**
1170 * @param {string} menuAction
1171 * @param {string} label
1172 */
1173 const addItem = (menuAction, label) => {
1174 const item = /** @type {HTMLElement} */ (this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu-item'));
1175 /** @type {HTMLElement} */
1176 const labelElement = querySelectorNotNull(item, '.popup-menu-item-label');
1177 labelElement.textContent = label;
1178 item.dataset.menuAction = menuAction;
1179 menuBodyNode.appendChild(item);
1180 };
1181
1182 addItem('log-debug-info', 'Log debug info');
1183
1184 this._menuContainer.appendChild(menuContainerNode);
1185 const popupMenu = new PopupMenu(node, menuContainerNode);
1186 popupMenu.prepare();
1187 }
1188
1189 /**
1190 * @param {import('popup-menu').MenuCloseEvent} e
1191 */
1192 _onMenuButtonMenuClose(e) {
1193 const node = /** @type {HTMLElement} */ (e.currentTarget);
1194 const {action} = e.detail;
1195 switch (action) {
1196 case 'log-debug-info':
1197 void this._logDictionaryEntryData(this.getElementDictionaryEntryIndex(node));
1198 break;
1199 }
1200 }
1201
1202 /**
1203 * @param {Element} tagNode
1204 */
1205 _showTagNotification(tagNode) {
1206 const parent = tagNode.parentNode;
1207 if (parent === null || !(parent instanceof HTMLElement)) { return; }
1208
1209 if (this._tagNotification === null) {
1210 this._tagNotification = this.createNotification(true);
1211 }
1212
1213 const index = this.getElementDictionaryEntryIndex(parent);
1214 const dictionaryEntry = (index >= 0 && index < this._dictionaryEntries.length ? this._dictionaryEntries[index] : null);
1215
1216 const content = this._displayGenerator.createTagFooterNotificationDetails(parent, dictionaryEntry);
1217 this._tagNotification.setContent(content);
1218 this._tagNotification.open();
1219 }
1220
1221 /**
1222 * @param {HTMLSpanElement} inflectionNode
1223 */
1224 _showInflectionNotification(inflectionNode) {
1225 const description = inflectionNode.title;
1226 if (!description || !(inflectionNode instanceof HTMLSpanElement)) { return; }
1227
1228 if (this._inflectionNotification === null) {
1229 this._inflectionNotification = this.createNotification(true);
1230 }
1231
1232 this._inflectionNotification.setContent(description);
1233 this._inflectionNotification.open();
1234 }
1235
1236 /**
1237 * @param {boolean} animate
1238 */
1239 _hideTagNotification(animate) {
1240 if (this._tagNotification === null) { return; }
1241 this._tagNotification.close(animate);
1242 }
1243
1244 /**
1245 * @param {boolean} animate
1246 */
1247 _hideInflectionNotification(animate) {
1248 if (this._inflectionNotification === null) { return; }
1249 this._inflectionNotification.close(animate);
1250 }
1251
1252 /**
1253 * @param {import('settings').ProfileOptions} options
1254 */
1255 _updateDocumentOptions(options) {
1256 const data = document.documentElement.dataset;
1257 data.ankiEnabled = `${options.anki.enable}`;
1258 data.language = options.general.language;
1259 data.resultOutputMode = `${options.general.resultOutputMode}`;
1260 data.glossaryLayoutMode = `${options.general.glossaryLayoutMode}`;
1261 data.compactTags = `${options.general.compactTags}`;
1262 data.averageFrequency = `${options.general.averageFrequency}`;
1263 data.frequencyDisplayMode = `${options.general.frequencyDisplayMode}`;
1264 data.termDisplayMode = `${options.general.termDisplayMode}`;
1265 data.enableSearchTags = `${options.scanning.enableSearchTags}`;
1266 data.showPronunciationText = `${options.general.showPitchAccentDownstepNotation}`;
1267 data.showPronunciationDownstepPosition = `${options.general.showPitchAccentPositionNotation}`;
1268 data.showPronunciationGraph = `${options.general.showPitchAccentGraph}`;
1269 data.debug = `${options.general.debugInfo}`;
1270 data.popupDisplayMode = `${options.general.popupDisplayMode}`;
1271 data.popupCurrentIndicatorMode = `${options.general.popupCurrentIndicatorMode}`;
1272 data.popupActionBarVisibility = `${options.general.popupActionBarVisibility}`;
1273 data.popupActionBarLocation = `${options.general.popupActionBarLocation}`;
1274 }
1275
1276 /**
1277 * @param {import('settings').ProfileOptions} options
1278 */
1279 _setTheme(options) {
1280 const {general} = options;
1281 const {popupTheme, popupOuterTheme, fontFamily, fontSize, lineHeight} = general;
1282 /** @type {string} */
1283 let pageType = this._pageType;
1284 try {
1285 // eslint-disable-next-line no-underscore-dangle
1286 const historyState = this._history._current.state;
1287
1288 const pageTheme = historyState?.pageTheme;
1289 this._themeController.siteTheme = pageTheme ?? null;
1290
1291 if (checkPopupPreviewURL(historyState?.url)) {
1292 pageType = 'popupPreview';
1293 }
1294 } catch (e) {
1295 log.error(e);
1296 }
1297 this._themeController.theme = popupTheme;
1298 this._themeController.outerTheme = popupOuterTheme;
1299 this._themeController.siteOverride = pageType === 'search' || pageType === 'popupPreview';
1300 this._themeController.updateTheme();
1301 const customCss = this._getCustomCss(options);
1302 this.setCustomCss(customCss);
1303 this.setFontOptions(fontFamily, fontSize, lineHeight);
1304 }
1305
1306 /**
1307 * @param {import('settings').ProfileOptions} options
1308 * @returns {string}
1309 */
1310 _getCustomCss(options) {
1311 const {general: {customPopupCss}, dictionaries} = options;
1312 let customCss = customPopupCss;
1313 for (const {name, enabled, styles = ''} of dictionaries) {
1314 if (enabled) {
1315 const escapedTitle = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1316 customCss += '\n' + addScopeToCss(styles, `[data-dictionary="${escapedTitle}"]`);
1317 }
1318 }
1319 this.setCustomCss(customCss);
1320 return customCss;
1321 }
1322
1323 /**
1324 * @param {boolean} isKanji
1325 * @param {string} source
1326 * @param {string} primaryReading
1327 * @param {boolean} wildcardsEnabled
1328 * @param {import('settings').OptionsContext} optionsContext
1329 * @returns {Promise<import('dictionary').DictionaryEntry[]>}
1330 */
1331 async _findDictionaryEntries(isKanji, source, primaryReading, wildcardsEnabled, optionsContext) {
1332 /** @type {import('dictionary').DictionaryEntry[]} */
1333 let dictionaryEntries = [];
1334 const {findDetails, source: source2} = this._getFindDetails(source, primaryReading, wildcardsEnabled);
1335 if (isKanji) {
1336 dictionaryEntries = await this._application.api.kanjiFind(source, optionsContext);
1337 if (dictionaryEntries.length > 0) { return dictionaryEntries; }
1338
1339 dictionaryEntries = (await this._application.api.termsFind(source2, findDetails, optionsContext)).dictionaryEntries;
1340 } else {
1341 dictionaryEntries = (await this._application.api.termsFind(source2, findDetails, optionsContext)).dictionaryEntries;
1342 if (dictionaryEntries.length > 0) { return dictionaryEntries; }
1343
1344 dictionaryEntries = await this._application.api.kanjiFind(source, optionsContext);
1345 }
1346 return dictionaryEntries;
1347 }
1348
1349 /**
1350 * @param {string} source
1351 * @param {string} primaryReading
1352 * @param {boolean} wildcardsEnabled
1353 * @returns {{findDetails: import('api').FindTermsDetails, source: string}}
1354 */
1355 _getFindDetails(source, primaryReading, wildcardsEnabled) {
1356 /** @type {import('api').FindTermsDetails} */
1357 const findDetails = {primaryReading};
1358 if (wildcardsEnabled) {
1359 const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(source);
1360 if (match !== null) {
1361 if (match[1]) {
1362 findDetails.matchType = 'suffix';
1363 findDetails.deinflect = false;
1364 } else if (match[3]) {
1365 findDetails.matchType = 'prefix';
1366 findDetails.deinflect = false;
1367 }
1368 source = match[2];
1369 }
1370 }
1371 return {findDetails, source};
1372 }
1373
1374 /**
1375 * @param {string} type
1376 * @param {URLSearchParams} urlSearchParams
1377 * @param {import('core').TokenObject} token
1378 */
1379 async _setContentTermsOrKanji(type, urlSearchParams, token) {
1380 const lookup = (urlSearchParams.get('lookup') !== 'false');
1381 const wildcardsEnabled = (urlSearchParams.get('wildcards') !== 'off');
1382 const hasEnabledDictionaries = this._options ? this._options.dictionaries.some(({enabled}) => enabled) : false;
1383
1384 // Set query
1385 safePerformance.mark('display:setQuery:start');
1386 let query = urlSearchParams.get('query');
1387 if (query === null) { query = ''; }
1388 let queryFull = urlSearchParams.get('full');
1389 queryFull = (queryFull !== null ? queryFull : query);
1390 const primaryReading = urlSearchParams.get('primary_reading') ?? '';
1391 const queryOffsetString = urlSearchParams.get('offset');
1392 let queryOffset = 0;
1393 if (queryOffsetString !== null) {
1394 queryOffset = Number.parseInt(queryOffsetString, 10);
1395 queryOffset = Number.isFinite(queryOffset) ? Math.max(0, Math.min(queryFull.length - query.length, queryOffset)) : 0;
1396 }
1397 this._setQuery(query, queryFull, queryOffset);
1398 safePerformance.mark('display:setQuery:end');
1399 safePerformance.measure('display:setQuery', 'display:setQuery:start', 'display:setQuery:end');
1400
1401 let {state, content} = this._history;
1402 let changeHistory = false;
1403 if (!(typeof content === 'object' && content !== null)) {
1404 content = {};
1405 changeHistory = true;
1406 }
1407 if (!(typeof state === 'object' && state !== null)) {
1408 state = {};
1409 changeHistory = true;
1410 }
1411
1412 let {focusEntry, scrollX, scrollY, optionsContext} = state;
1413 if (typeof focusEntry !== 'number') { focusEntry = 0; }
1414 if (!(typeof optionsContext === 'object' && optionsContext !== null)) {
1415 optionsContext = this.getOptionsContext();
1416 state.optionsContext = optionsContext;
1417 changeHistory = true;
1418 }
1419
1420 let {dictionaryEntries} = content;
1421 if (!Array.isArray(dictionaryEntries)) {
1422 safePerformance.mark('display:findDictionaryEntries:start');
1423 dictionaryEntries = hasEnabledDictionaries && lookup && query.length > 0 ? await this._findDictionaryEntries(type === 'kanji', query, primaryReading, wildcardsEnabled, optionsContext) : [];
1424 safePerformance.mark('display:findDictionaryEntries:end');
1425 safePerformance.measure('display:findDictionaryEntries', 'display:findDictionaryEntries:start', 'display:findDictionaryEntries:end');
1426 if (this._setContentToken !== token) { return; }
1427 if (lookup) {
1428 content.dictionaryEntries = dictionaryEntries;
1429 }
1430 changeHistory = true;
1431 }
1432
1433 let contentOriginValid = false;
1434 const {contentOrigin} = content;
1435 if (typeof contentOrigin === 'object' && contentOrigin !== null) {
1436 const {tabId, frameId} = contentOrigin;
1437 if (tabId !== null && frameId !== null) {
1438 this._contentOriginTabId = tabId;
1439 this._contentOriginFrameId = frameId;
1440 contentOriginValid = true;
1441 }
1442 }
1443 if (!contentOriginValid) {
1444 content.contentOrigin = this.getContentOrigin();
1445 changeHistory = true;
1446 }
1447
1448 await this._setOptionsContextIfDifferent(optionsContext);
1449 if (this._setContentToken !== token) { return; }
1450
1451 if (this._options === null) {
1452 await this.updateOptions();
1453 if (this._setContentToken !== token) { return; }
1454 }
1455
1456 if (changeHistory) {
1457 this._replaceHistoryStateNoNavigate(state, content);
1458 }
1459
1460 this._dictionaryEntries = dictionaryEntries;
1461
1462 safePerformance.mark('display:updateNavigationAuto:start');
1463 this._updateNavigationAuto();
1464 safePerformance.mark('display:updateNavigationAuto:end');
1465 safePerformance.measure('display:updateNavigationAuto', 'display:updateNavigationAuto:start', 'display:updateNavigationAuto:end');
1466
1467 this._setNoContentVisible(hasEnabledDictionaries && dictionaryEntries.length === 0 && lookup);
1468 this._setNoDictionariesVisible(!hasEnabledDictionaries);
1469
1470 const container = this._container;
1471 container.textContent = '';
1472
1473 safePerformance.mark('display:contentUpdate:start');
1474 this._triggerContentUpdateStart();
1475
1476 let i = 0;
1477 for (const dictionaryEntry of dictionaryEntries) {
1478 safePerformance.mark('display:createEntry:start');
1479
1480 if (i > 0) {
1481 await promiseTimeout(1);
1482 if (this._setContentToken !== token) { return; }
1483 }
1484
1485 safePerformance.mark('display:createEntryReal:start');
1486
1487 const entry = (
1488 dictionaryEntry.type === 'term' ?
1489 this._displayGenerator.createTermEntry(dictionaryEntry, this._dictionaryInfo) :
1490 this._displayGenerator.createKanjiEntry(dictionaryEntry, this._dictionaryInfo)
1491 );
1492 entry.dataset.index = `${i}`;
1493 this._dictionaryEntryNodes.push(entry);
1494 this._addEntryEventListeners(entry);
1495 this._triggerContentUpdateEntry(dictionaryEntry, entry, i);
1496 if (this._setContentToken !== token) { return; }
1497 container.appendChild(entry);
1498
1499 if (focusEntry === i) {
1500 this._focusEntry(i, 0, false);
1501 }
1502
1503 this._elementOverflowController.addElements(entry);
1504
1505 safePerformance.mark('display:createEntryReal:end');
1506 safePerformance.measure('display:createEntryReal', 'display:createEntryReal:start', 'display:createEntryReal:end');
1507
1508 safePerformance.mark('display:createEntry:end');
1509 safePerformance.measure('display:createEntry', 'display:createEntry:start', 'display:createEntry:end');
1510
1511 if (i === 0) {
1512 void this._contentManager.executeMediaRequests(); // prioritize loading media for first entry since it is visible
1513 }
1514 ++i;
1515 }
1516 if (this._setContentToken !== token) { return; }
1517 void this._contentManager.executeMediaRequests();
1518
1519 if (typeof scrollX === 'number' || typeof scrollY === 'number') {
1520 let {x, y} = this._windowScroll;
1521 if (typeof scrollX === 'number') { x = scrollX; }
1522 if (typeof scrollY === 'number') { y = scrollY; }
1523 this._windowScroll.stop();
1524 this._windowScroll.to(x, y);
1525 }
1526
1527 this._triggerContentUpdateComplete();
1528 safePerformance.mark('display:contentUpdate:end');
1529 safePerformance.measure('display:contentUpdate', 'display:contentUpdate:start', 'display:contentUpdate:end');
1530 }
1531
1532 /** */
1533 _setContentExtensionUnloaded() {
1534 /** @type {?HTMLElement} */
1535 const errorExtensionUnloaded = document.querySelector('#error-extension-unloaded');
1536
1537 if (this._container !== null) {
1538 this._container.hidden = true;
1539 }
1540
1541 if (errorExtensionUnloaded !== null) {
1542 errorExtensionUnloaded.hidden = false;
1543 }
1544
1545 this._updateNavigation(false, false);
1546 this._setNoContentVisible(false);
1547 this._setNoDictionariesVisible(false);
1548 this._setQuery('', '', 0);
1549
1550 this._triggerContentUpdateStart();
1551 this._triggerContentUpdateComplete();
1552 }
1553
1554 /** */
1555 _clearContent() {
1556 this._container.textContent = '';
1557 this._updateNavigationAuto();
1558 this._setQuery('', '', 0);
1559
1560 this._triggerContentUpdateStart();
1561 this._triggerContentUpdateComplete();
1562 }
1563
1564 /**
1565 * @param {boolean} visible
1566 */
1567 _setNoContentVisible(visible) {
1568 /** @type {?HTMLElement} */
1569 const noResults = document.querySelector('#no-results');
1570
1571 if (noResults !== null) {
1572 noResults.hidden = !visible;
1573 }
1574 }
1575
1576 /**
1577 * @param {boolean} visible
1578 */
1579 _setNoDictionariesVisible(visible) {
1580 /** @type {?HTMLElement} */
1581 const noDictionaries = document.querySelector('#no-dictionaries');
1582
1583 if (noDictionaries !== null) {
1584 noDictionaries.hidden = !visible;
1585 }
1586 }
1587
1588 /**
1589 * @param {string} query
1590 * @param {string} fullQuery
1591 * @param {number} queryOffset
1592 */
1593 _setQuery(query, fullQuery, queryOffset) {
1594 this._query = query;
1595 this._fullQuery = fullQuery;
1596 this._queryOffset = queryOffset;
1597 this._updateQueryParser();
1598 this._setTitleText(query);
1599 }
1600
1601 /** */
1602 _updateQueryParser() {
1603 const text = this._fullQuery;
1604 const visible = this._isQueryParserVisible();
1605 this._queryParserContainer.hidden = !visible || text.length === 0;
1606 if (visible && this._queryParser.text !== text) {
1607 void this._setQueryParserText(text);
1608 }
1609 }
1610
1611 /**
1612 * @param {string} text
1613 */
1614 async _setQueryParserText(text) {
1615 const overrideToken = this._progressIndicatorVisible.setOverride(true);
1616 try {
1617 await this._queryParser.setText(text);
1618 } finally {
1619 this._progressIndicatorVisible.clearOverride(overrideToken);
1620 }
1621 }
1622
1623 /**
1624 * @param {string} text
1625 */
1626 _setTitleText(text) {
1627 let title = this._defaultTitle;
1628 if (text.length > 0) {
1629 // Chrome limits title to 1024 characters
1630 const ellipsis = '...';
1631 const separator = ' - ';
1632 const maxLength = this._titleMaxLength - title.length - separator.length;
1633 if (text.length > maxLength) {
1634 text = `${text.substring(0, Math.max(0, maxLength - ellipsis.length))}${ellipsis}`;
1635 }
1636
1637 title = `${text}${separator}${title}`;
1638 }
1639 document.title = title;
1640 }
1641
1642 /** */
1643 _updateNavigationAuto() {
1644 this._updateNavigation(this._history.hasPrevious(), this._history.hasNext());
1645 }
1646
1647 /**
1648 * @param {boolean} previous
1649 * @param {boolean} next
1650 */
1651 _updateNavigation(previous, next) {
1652 const {documentElement} = document;
1653 if (documentElement !== null) {
1654 documentElement.dataset.hasNavigationPrevious = `${previous}`;
1655 documentElement.dataset.hasNavigationNext = `${next}`;
1656 }
1657 if (this._navigationPreviousButton !== null) {
1658 this._navigationPreviousButton.disabled = !previous;
1659 }
1660 if (this._navigationNextButton !== null) {
1661 this._navigationNextButton.disabled = !next;
1662 }
1663 }
1664
1665 /**
1666 * @param {number} index
1667 */
1668 _entrySetCurrent(index) {
1669 const entryPre = this._getEntry(this._index);
1670 if (entryPre !== null) {
1671 entryPre.classList.remove('entry-current');
1672 }
1673
1674 const entry = this._getEntry(index);
1675 if (entry !== null) {
1676 entry.classList.add('entry-current');
1677 }
1678
1679 this._index = index;
1680 }
1681
1682 /**
1683 * @param {number} index
1684 * @param {number} definitionIndex
1685 * @param {boolean} smooth
1686 */
1687 _focusEntry(index, definitionIndex, smooth) {
1688 index = Math.max(Math.min(index, this._dictionaryEntries.length - 1), 0);
1689
1690 this._entrySetCurrent(index);
1691
1692 let node = (index >= 0 && index < this._dictionaryEntryNodes.length ? this._dictionaryEntryNodes[index] : null);
1693 if (definitionIndex > 0) {
1694 const definitionNodes = this._getDictionaryEntryDefinitionNodes(index);
1695 if (definitionIndex < definitionNodes.length) {
1696 node = definitionNodes[definitionIndex];
1697 }
1698 }
1699 let target = (index === 0 && definitionIndex <= 0) || node === null ? 0 : this._getElementTop(node);
1700
1701 if (target !== 0) {
1702 if (this._aboveStickyHeader !== null) {
1703 target += this._aboveStickyHeader.getBoundingClientRect().height;
1704 }
1705 if (!this._options?.general.stickySearchHeader && this._searchHeader) {
1706 target += this._searchHeader.getBoundingClientRect().height;
1707 }
1708 }
1709
1710 this._windowScroll.stop();
1711 if (smooth) {
1712 this._windowScroll.animate(this._windowScroll.x, target, 200);
1713 } else {
1714 this._windowScroll.toY(target);
1715 }
1716 }
1717
1718 /**
1719 * @param {number} offset
1720 * @param {boolean} smooth
1721 * @returns {boolean}
1722 */
1723 _focusEntryWithDifferentDictionary(offset, smooth) {
1724 const sign = Math.sign(offset);
1725 if (sign === 0) { return false; }
1726
1727 let index = this._index;
1728 const count = Math.min(this._dictionaryEntries.length, this._dictionaryEntryNodes.length);
1729 if (index < 0 || index >= count) { return false; }
1730
1731 const dictionaryEntry = this._dictionaryEntries[index];
1732 const visibleDefinitionIndex = this._getDictionaryEntryVisibleDefinitionIndex(index, sign);
1733 if (visibleDefinitionIndex === null) { return false; }
1734
1735 let focusDefinitionIndex = null;
1736 if (dictionaryEntry.type === 'term') {
1737 const {dictionary} = dictionaryEntry.definitions[visibleDefinitionIndex];
1738 for (let i = index; i >= 0 && i < count; i += sign) {
1739 const otherDictionaryEntry = this._dictionaryEntries[i];
1740 if (otherDictionaryEntry.type !== 'term') { continue; }
1741 const {definitions} = otherDictionaryEntry;
1742 const jj = definitions.length;
1743 let j = (i === index ? visibleDefinitionIndex + sign : (sign > 0 ? 0 : jj - 1));
1744 for (; j >= 0 && j < jj; j += sign) {
1745 if (definitions[j].dictionary !== dictionary) {
1746 focusDefinitionIndex = j;
1747 index = i;
1748 i = -2; // Terminate outer loop
1749 break;
1750 }
1751 }
1752 }
1753 }
1754
1755 if (focusDefinitionIndex === null) { return false; }
1756
1757 this._focusEntry(index, focusDefinitionIndex, smooth);
1758 return true;
1759 }
1760
1761 /**
1762 *
1763 * @param {number} direction
1764 * @param {number} scale
1765 */
1766 _scrollByPopupHeight(direction, scale) {
1767 const popupHeight = this._contentScrollElement.clientHeight;
1768 const contentBottom = this._contentScrollElement.scrollHeight - popupHeight;
1769 const scrollAmount = popupHeight * scale * direction;
1770 const target = Math.min(this._windowScroll.y + scrollAmount, contentBottom);
1771
1772 this._windowScroll.stop();
1773 this._windowScroll.toY(Math.max(0, target));
1774 }
1775
1776 /**
1777 * @param {number} index
1778 * @param {number} sign
1779 * @returns {?number}
1780 */
1781 _getDictionaryEntryVisibleDefinitionIndex(index, sign) {
1782 const {top: scrollTop, bottom: scrollBottom} = this._windowScroll.getRect();
1783
1784 const {definitions} = this._dictionaryEntries[index];
1785 const nodes = this._getDictionaryEntryDefinitionNodes(index);
1786 const definitionCount = Math.min(definitions.length, nodes.length);
1787 if (definitionCount <= 0) { return null; }
1788
1789 let visibleIndex = null;
1790 let visibleCoverage = 0;
1791 for (let i = (sign > 0 ? 0 : definitionCount - 1); i >= 0 && i < definitionCount; i += sign) {
1792 const {top, bottom} = nodes[i].getBoundingClientRect();
1793 if (bottom <= scrollTop || top >= scrollBottom) { continue; }
1794 const top2 = Math.max(scrollTop, Math.min(scrollBottom, top));
1795 const bottom2 = Math.max(scrollTop, Math.min(scrollBottom, bottom));
1796 const coverage = (bottom2 - top2) / (bottom - top);
1797 if (coverage >= visibleCoverage) {
1798 visibleCoverage = coverage;
1799 visibleIndex = i;
1800 }
1801 }
1802
1803 return visibleIndex !== null ? visibleIndex : (sign > 0 ? definitionCount - 1 : 0);
1804 }
1805
1806 /**
1807 * @param {number} index
1808 * @returns {NodeListOf<HTMLElement>}
1809 */
1810 _getDictionaryEntryDefinitionNodes(index) {
1811 return this._dictionaryEntryNodes[index].querySelectorAll('.definition-item');
1812 }
1813
1814 /** */
1815 _sourceTermView() {
1816 this._relativeTermView(false);
1817 }
1818
1819 /** */
1820 _nextTermView() {
1821 this._relativeTermView(true);
1822 }
1823
1824 /**
1825 * @param {boolean} next
1826 * @returns {boolean}
1827 */
1828 _relativeTermView(next) {
1829 return (
1830 next ?
1831 this._history.hasNext() && this._history.forward() :
1832 this._history.hasPrevious() && this._history.back()
1833 );
1834 }
1835
1836 /**
1837 * @param {number} index
1838 * @returns {?HTMLElement}
1839 */
1840 _getEntry(index) {
1841 const entries = this._dictionaryEntryNodes;
1842 return index >= 0 && index < entries.length ? entries[index] : null;
1843 }
1844
1845 /**
1846 * @param {Element} element
1847 * @returns {number}
1848 */
1849 _getElementTop(element) {
1850 const elementRect = element.getBoundingClientRect();
1851 const documentRect = this._contentScrollBodyElement.getBoundingClientRect();
1852 return elementRect.top - documentRect.top;
1853 }
1854
1855 /** */
1856 _updateHistoryState() {
1857 const {state, content} = this._history;
1858 if (!(typeof state === 'object' && state !== null)) { return; }
1859
1860 state.focusEntry = this._index;
1861 state.scrollX = this._windowScroll.x;
1862 state.scrollY = this._windowScroll.y;
1863 this._replaceHistoryStateNoNavigate(state, content);
1864 }
1865
1866 /**
1867 * @param {import('display-history').EntryState} state
1868 * @param {?import('display-history').EntryContent} content
1869 */
1870 _replaceHistoryStateNoNavigate(state, content) {
1871 const historyChangeIgnorePre = this._historyChangeIgnore;
1872 try {
1873 this._historyChangeIgnore = true;
1874 this._history.replaceState(state, content);
1875 } finally {
1876 this._historyChangeIgnore = historyChangeIgnorePre;
1877 }
1878 }
1879
1880 /**
1881 * @param {import('display').PageType} type
1882 * @param {string} query
1883 * @param {boolean} wildcards
1884 * @param {?number} sentenceOffset
1885 * @returns {import('display').HistoryParams}
1886 */
1887 _createSearchParams(type, query, wildcards, sentenceOffset) {
1888 /** @type {import('display').HistoryParams} */
1889 const params = {};
1890 const fullQuery = this._fullQuery;
1891 const includeFull = (query.length < fullQuery.length);
1892 if (includeFull) {
1893 params.full = fullQuery;
1894 }
1895 params.query = query;
1896 if (includeFull && sentenceOffset !== null) {
1897 params.offset = `${sentenceOffset}`;
1898 }
1899 if (typeof type === 'string') {
1900 params.type = type;
1901 }
1902 if (!wildcards) {
1903 params.wildcards = 'off';
1904 }
1905 if (this._queryParserVisibleOverride !== null) {
1906 params['full-visible'] = `${this._queryParserVisibleOverride}`;
1907 }
1908 return params;
1909 }
1910
1911 /**
1912 * @returns {boolean}
1913 */
1914 _isQueryParserVisible() {
1915 return (
1916 this._queryParserVisibleOverride !== null ?
1917 this._queryParserVisibleOverride :
1918 this._queryParserVisible
1919 );
1920 }
1921
1922 /** */
1923 _closePopups() {
1924 this._application.triggerClosePopups();
1925 }
1926
1927 /**
1928 * @param {import('settings').OptionsContext} optionsContext
1929 */
1930 async _setOptionsContextIfDifferent(optionsContext) {
1931 if (deepEqual(this._optionsContext, optionsContext)) { return; }
1932 await this.setOptionsContext(optionsContext);
1933 }
1934
1935 /**
1936 * @param {number} scale
1937 */
1938 _setContentScale(scale) {
1939 const body = document.body;
1940 if (body === null) { return; }
1941 body.style.fontSize = `${scale}em`;
1942 }
1943
1944 /**
1945 * @param {import('settings').ProfileOptions} options
1946 */
1947 async _updateNestedFrontend(options) {
1948 const {tabId, frameId} = this._application;
1949 if (tabId === null || frameId === null) { return; }
1950
1951 const isSearchPage = (this._pageType === 'search');
1952 const isEnabled = (
1953 this._childrenSupported &&
1954 (
1955 (isSearchPage) ?
1956 (options.scanning.enableOnSearchPage) :
1957 (this._depth < options.scanning.popupNestingMaxDepth)
1958 )
1959 );
1960
1961 if (this._frontend === null) {
1962 if (!isEnabled) { return; }
1963
1964 try {
1965 if (this._frontendSetupPromise === null) {
1966 this._frontendSetupPromise = this._setupNestedFrontend();
1967 }
1968 await this._frontendSetupPromise;
1969 } catch (e) {
1970 log.error(e);
1971 return;
1972 } finally {
1973 this._frontendSetupPromise = null;
1974 }
1975 }
1976
1977 /** @type {import('../app/frontend.js').Frontend} */ (this._frontend).setDisabledOverride(!isEnabled);
1978 }
1979
1980 /** */
1981 async _setupNestedFrontend() {
1982 const useProxyPopup = this._parentFrameId !== null;
1983 const parentPopupId = this._parentPopupId;
1984 const parentFrameId = this._parentFrameId;
1985
1986 const [{PopupFactory}, {Frontend}] = await Promise.all([
1987 import('../app/popup-factory.js'),
1988 import('../app/frontend.js'),
1989 ]);
1990
1991 const popupFactory = new PopupFactory(this._application);
1992 popupFactory.prepare();
1993
1994 const frontend = new Frontend({
1995 application: this._application,
1996 useProxyPopup,
1997 parentPopupId,
1998 parentFrameId,
1999 depth: this._depth + 1,
2000 popupFactory,
2001 pageType: this._pageType,
2002 allowRootFramePopupProxy: true,
2003 childrenSupported: this._childrenSupported,
2004 hotkeyHandler: this._hotkeyHandler,
2005 canUseWindowPopup: true,
2006 });
2007 this._frontend = frontend;
2008 await frontend.prepare();
2009 }
2010
2011 /**
2012 * @returns {boolean}
2013 */
2014 _copyHostSelection() {
2015 if (typeof this._contentOriginFrameId !== 'number') { return false; }
2016 const selection = window.getSelection();
2017 if (selection !== null && selection.toString().length > 0) { return false; }
2018 void this._copyHostSelectionSafe();
2019 return true;
2020 }
2021
2022 /** */
2023 async _copyHostSelectionSafe() {
2024 try {
2025 await this._copyHostSelectionInner();
2026 } catch (e) {
2027 // NOP
2028 }
2029 }
2030
2031 /** */
2032 async _copyHostSelectionInner() {
2033 switch (this._browser) {
2034 case 'firefox':
2035 case 'firefox-mobile':
2036 {
2037 /** @type {string} */
2038 let text;
2039 try {
2040 text = await this.invokeContentOrigin('frontendGetPopupSelectionText', void 0);
2041 } catch (e) {
2042 break;
2043 }
2044 this._copyText(text);
2045 }
2046 break;
2047 default:
2048 await this.invokeContentOrigin('frontendCopySelection', void 0);
2049 break;
2050 }
2051 }
2052
2053 /**
2054 * @param {string} text
2055 */
2056 _copyText(text) {
2057 const parent = document.body;
2058 if (parent === null) { return; }
2059
2060 let textarea = this._copyTextarea;
2061 if (textarea === null) {
2062 textarea = document.createElement('textarea');
2063 this._copyTextarea = textarea;
2064 }
2065
2066 textarea.value = text;
2067 parent.appendChild(textarea);
2068 textarea.select();
2069 document.execCommand('copy');
2070 parent.removeChild(textarea);
2071 }
2072
2073 /**
2074 * @param {HTMLElement} entry
2075 */
2076 _addEntryEventListeners(entry) {
2077 const eventListeners = this._eventListeners;
2078 eventListeners.addEventListener(entry, 'click', this._onEntryClickBind);
2079 for (const node of entry.querySelectorAll('.headword-kanji-link')) {
2080 eventListeners.addEventListener(node, 'click', this._onKanjiLookupBind);
2081 }
2082 for (const node of entry.querySelectorAll('.inflection[data-reason]')) {
2083 eventListeners.addEventListener(node, 'click', this._onInflectionClickBind);
2084 }
2085 for (const node of entry.querySelectorAll('.tag-label')) {
2086 eventListeners.addEventListener(node, 'click', this._onTagClickBind);
2087 }
2088 for (const node of entry.querySelectorAll('.action-button[data-action=menu]')) {
2089 eventListeners.addEventListener(node, 'click', this._onMenuButtonClickBind);
2090 eventListeners.addEventListener(node, 'menuClose', this._onMenuButtonMenuCloseBind);
2091 }
2092 }
2093
2094 /**
2095 * @param {import('settings').ProfileOptions} options
2096 */
2097 _updateContentTextScanner(options) {
2098 if (!options.scanning.enablePopupSearch || (!options.scanning.enableOnSearchPage && this._pageType === 'search')) {
2099 if (this._contentTextScanner !== null) {
2100 this._contentTextScanner.setEnabled(false);
2101 this._contentTextScanner.clearSelection();
2102 }
2103 return;
2104 }
2105
2106 if (this._contentTextScanner === null) {
2107 this._contentTextScanner = new TextScanner({
2108 api: this._application.api,
2109 node: window,
2110 getSearchContext: this._getSearchContext.bind(this),
2111 searchTerms: true,
2112 searchKanji: false,
2113 searchOnClick: true,
2114 searchOnClickOnly: true,
2115 textSourceGenerator: this._textSourceGenerator,
2116 });
2117 this._contentTextScanner.includeSelector = '.click-scannable,.click-scannable *';
2118 this._contentTextScanner.excludeSelector = '.scan-disable,.scan-disable *';
2119 this._contentTextScanner.touchEventExcludeSelector = null;
2120 this._contentTextScanner.prepare();
2121 this._contentTextScanner.on('clear', this._onContentTextScannerClear.bind(this));
2122 this._contentTextScanner.on('searchSuccess', this._onContentTextScannerSearchSuccess.bind(this));
2123 this._contentTextScanner.on('searchError', this._onContentTextScannerSearchError.bind(this));
2124 }
2125
2126 const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options;
2127 this._contentTextScanner.language = options.general.language;
2128 this._contentTextScanner.setOptions({
2129 inputs: [{
2130 include: 'mouse0',
2131 exclude: '',
2132 types: {mouse: true, pen: false, touch: false},
2133 options: {
2134 searchTerms: true,
2135 searchKanji: true,
2136 scanOnTouchTap: true,
2137 scanOnTouchMove: false,
2138 scanOnTouchPress: false,
2139 scanOnTouchRelease: false,
2140 scanOnPenMove: false,
2141 scanOnPenHover: false,
2142 scanOnPenReleaseHover: false,
2143 scanOnPenPress: false,
2144 scanOnPenRelease: false,
2145 preventTouchScrolling: false,
2146 preventPenScrolling: false,
2147 minimumTouchTime: 0,
2148 },
2149 }],
2150 deepContentScan: scanningOptions.deepDomScan,
2151 normalizeCssZoom: scanningOptions.normalizeCssZoom,
2152 selectText: false,
2153 delay: scanningOptions.delay,
2154 scanLength: scanningOptions.length,
2155 layoutAwareScan: scanningOptions.layoutAwareScan,
2156 preventMiddleMouseOnPage: false,
2157 preventMiddleMouseOnTextHover: false,
2158 preventBackForwardOnPage: false,
2159 preventBackForwardOnTextHover: false,
2160 sentenceParsingOptions,
2161 pageType: this._pageType,
2162 });
2163
2164 this._contentTextScanner.setEnabled(true);
2165 }
2166
2167 /** */
2168 _onContentTextScannerClear() {
2169 /** @type {TextScanner} */ (this._contentTextScanner).clearSelection();
2170 }
2171
2172 /**
2173 * @param {import('text-scanner').EventArgument<'searchSuccess'>} details
2174 */
2175 _onContentTextScannerSearchSuccess({type, dictionaryEntries, sentence, textSource, optionsContext}) {
2176 const query = textSource.text();
2177 const url = window.location.href;
2178 const documentTitle = document.title;
2179 /** @type {import('display').ContentDetails} */
2180 const details = {
2181 focus: false,
2182 historyMode: 'new',
2183 params: {
2184 type,
2185 query,
2186 wildcards: 'off',
2187 },
2188 state: {
2189 focusEntry: 0,
2190 optionsContext: optionsContext !== null ? optionsContext : void 0,
2191 url,
2192 sentence: sentence !== null ? sentence : void 0,
2193 documentTitle,
2194 pageTheme: 'light',
2195 },
2196 content: {
2197 dictionaryEntries: dictionaryEntries !== null ? dictionaryEntries : void 0,
2198 contentOrigin: this.getContentOrigin(),
2199 },
2200 };
2201 /** @type {TextScanner} */ (this._contentTextScanner).clearSelection();
2202 this.setContent(details);
2203 }
2204
2205 /**
2206 * @param {import('text-scanner').EventArgument<'searchError'>} details
2207 */
2208 _onContentTextScannerSearchError({error}) {
2209 if (!this._application.webExtension.unloaded) {
2210 log.error(error);
2211 }
2212 }
2213
2214 /**
2215 * @type {import('display').GetSearchContextCallback}
2216 */
2217 _getSearchContext() {
2218 return {
2219 optionsContext: this.getOptionsContext(),
2220 detail: {
2221 documentTitle: document.title,
2222 },
2223 };
2224 }
2225
2226 /**
2227 * @param {import('settings').ProfileOptions} options
2228 */
2229 _updateHotkeys(options) {
2230 this._hotkeyHandler.setHotkeys(this._pageType, options.inputs.hotkeys);
2231 }
2232
2233 /**
2234 * @returns {Promise<?chrome.tabs.Tab>}
2235 */
2236 _getCurrentTab() {
2237 return new Promise((resolve, reject) => {
2238 chrome.tabs.getCurrent((result) => {
2239 const e = chrome.runtime.lastError;
2240 if (e) {
2241 reject(new Error(e.message));
2242 } else {
2243 resolve(typeof result !== 'undefined' ? result : null);
2244 }
2245 });
2246 });
2247 }
2248
2249 /**
2250 * @param {number} tabId
2251 * @returns {Promise<void>}
2252 */
2253 _removeTab(tabId) {
2254 return new Promise((resolve, reject) => {
2255 chrome.tabs.remove(tabId, () => {
2256 const e = chrome.runtime.lastError;
2257 if (e) {
2258 reject(new Error(e.message));
2259 } else {
2260 resolve();
2261 }
2262 });
2263 });
2264 }
2265
2266 /** */
2267 async _closeTab() {
2268 const tab = await this._getCurrentTab();
2269 if (tab === null) { return; }
2270 const tabId = tab.id;
2271 if (typeof tabId === 'undefined') { return; }
2272 await this._removeTab(tabId);
2273 }
2274
2275 /** */
2276 _onHotkeyClose() {
2277 if (this._closeSinglePopupMenu()) { return; }
2278 this.close();
2279 }
2280
2281 /**
2282 * @param {number} sign
2283 * @param {unknown} argument
2284 */
2285 _onHotkeyActionMoveRelative(sign, argument) {
2286 let count = typeof argument === 'number' ? argument : (typeof argument === 'string' ? Number.parseInt(argument, 10) : 0);
2287 if (!Number.isFinite(count)) { count = 1; }
2288 count = Math.max(0, Math.floor(count));
2289 this._focusEntry(this._index + count * sign, 0, true);
2290 }
2291
2292 /** */
2293 _closeAllPopupMenus() {
2294 for (const popupMenu of PopupMenu.openMenus) {
2295 popupMenu.close();
2296 }
2297 }
2298
2299 /**
2300 * @returns {boolean}
2301 */
2302 _closeSinglePopupMenu() {
2303 for (const popupMenu of PopupMenu.openMenus) {
2304 popupMenu.close();
2305 return true;
2306 }
2307 return false;
2308 }
2309
2310 /**
2311 * @param {number} index
2312 */
2313 async _logDictionaryEntryData(index) {
2314 if (index < 0 || index >= this._dictionaryEntries.length) { return; }
2315 const dictionaryEntry = this._dictionaryEntries[index];
2316 const result = {dictionaryEntry};
2317
2318 /** @type {Promise<unknown>[]} */
2319 const promises = [];
2320 this.trigger('logDictionaryEntryData', {dictionaryEntry, promises});
2321 if (promises.length > 0) {
2322 for (const result2 of await Promise.all(promises)) {
2323 Object.assign(result, result2);
2324 }
2325 }
2326
2327 log.log(result);
2328 }
2329
2330 /** */
2331 _triggerContentClear() {
2332 this.trigger('contentClear', {});
2333 }
2334
2335 /** */
2336 _triggerContentUpdateStart() {
2337 this.trigger('contentUpdateStart', {type: this._contentType, query: this._query});
2338 }
2339
2340 /**
2341 * @param {import('dictionary').DictionaryEntry} dictionaryEntry
2342 * @param {Element} element
2343 * @param {number} index
2344 */
2345 _triggerContentUpdateEntry(dictionaryEntry, element, index) {
2346 this.trigger('contentUpdateEntry', {dictionaryEntry, element, index});
2347 }
2348
2349 /** */
2350 _triggerContentUpdateComplete() {
2351 this.trigger('contentUpdateComplete', {type: this._contentType});
2352 }
2353
2354 /**
2355 * @param {import('settings').ProfileOptions} options
2356 */
2357 _setStickyHeader(options) {
2358 if (this._searchHeader && options) {
2359 this._searchHeader.classList.toggle('sticky-header', options.general.stickySearchHeader);
2360 }
2361 }
2362}