Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 2362 lines 86 kB view raw
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}