Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 719 lines 26 kB view raw
1/* 2 * Copyright (C) 2023-2025 Yomitan Authors 3 * Copyright (C) 2016-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 {ClipboardMonitor} from '../comm/clipboard-monitor.js'; 20import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; 21import {EventListenerCollection} from '../core/event-listener-collection.js'; 22import {querySelectorNotNull} from '../dom/query-selector.js'; 23import {isComposing} from '../language/ime-utilities.js'; 24import {convertToKana, convertToKanaIME} from '../language/ja/japanese-wanakana.js'; 25 26export class SearchDisplayController { 27 /** 28 * @param {import('./display.js').Display} display 29 * @param {import('./display-audio.js').DisplayAudio} displayAudio 30 * @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController 31 */ 32 constructor(display, displayAudio, searchPersistentStateController) { 33 /** @type {import('./display.js').Display} */ 34 this._display = display; 35 /** @type {import('./display-audio.js').DisplayAudio} */ 36 this._displayAudio = displayAudio; 37 /** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */ 38 this._searchPersistentStateController = searchPersistentStateController; 39 /** @type {HTMLButtonElement} */ 40 this._searchButton = querySelectorNotNull(document, '#search-button'); 41 /** @type {HTMLButtonElement} */ 42 this._clearButton = querySelectorNotNull(document, '#clear-button'); 43 /** @type {HTMLButtonElement} */ 44 this._searchBackButton = querySelectorNotNull(document, '#search-back-button'); 45 /** @type {HTMLTextAreaElement} */ 46 this._queryInput = querySelectorNotNull(document, '#search-textbox'); 47 /** @type {HTMLElement} */ 48 this._introElement = querySelectorNotNull(document, '#intro'); 49 /** @type {HTMLInputElement} */ 50 this._clipboardMonitorEnableCheckbox = querySelectorNotNull(document, '#clipboard-monitor-enable'); 51 /** @type {HTMLInputElement} */ 52 this._wanakanaEnableCheckbox = querySelectorNotNull(document, '#wanakana-enable'); 53 /** @type {HTMLInputElement} */ 54 this._stickyHeaderEnableCheckbox = querySelectorNotNull(document, '#sticky-header-enable'); 55 /** @type {HTMLElement} */ 56 this._profileSelectContainer = querySelectorNotNull(document, '#search-option-profile-select'); 57 /** @type {HTMLSelectElement} */ 58 this._profileSelect = querySelectorNotNull(document, '#profile-select'); 59 /** @type {HTMLElement} */ 60 this._wanakanaSearchOption = querySelectorNotNull(document, '#search-option-wanakana'); 61 /** @type {EventListenerCollection} */ 62 this._queryInputEvents = new EventListenerCollection(); 63 /** @type {boolean} */ 64 this._queryInputEventsSetup = false; 65 /** @type {boolean} */ 66 this._wanakanaEnabled = false; 67 /** @type {boolean} */ 68 this._introVisible = true; 69 /** @type {?import('core').Timeout} */ 70 this._introAnimationTimer = null; 71 /** @type {boolean} */ 72 this._clipboardMonitorEnabled = false; 73 /** @type {import('clipboard-monitor').ClipboardReaderLike} */ 74 this._clipboardReaderLike = { 75 getText: this._display.application.api.clipboardGet.bind(this._display.application.api), 76 }; 77 /** @type {ClipboardMonitor} */ 78 this._clipboardMonitor = new ClipboardMonitor(this._clipboardReaderLike); 79 /** @type {import('application').ApiMap} */ 80 this._apiMap = createApiMap([ 81 ['searchDisplayControllerGetMode', this._onMessageGetMode.bind(this)], 82 ['searchDisplayControllerSetMode', this._onMessageSetMode.bind(this)], 83 ['searchDisplayControllerUpdateSearchQuery', this._onExternalSearchUpdate.bind(this)], 84 ]); 85 } 86 87 /** */ 88 async prepare() { 89 await this._display.updateOptions(); 90 91 this._searchPersistentStateController.on('modeChange', this._onModeChange.bind(this)); 92 93 chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); 94 this._display.application.on('optionsUpdated', this._onOptionsUpdated.bind(this)); 95 96 this._display.on('optionsUpdated', this._onDisplayOptionsUpdated.bind(this)); 97 this._display.on('contentUpdateStart', this._onContentUpdateStart.bind(this)); 98 99 this._display.hotkeyHandler.registerActions([ 100 ['focusSearchBox', this._onActionFocusSearchBox.bind(this)], 101 ]); 102 103 this._updateClipboardMonitorEnabled(); 104 105 this._displayAudio.autoPlayAudioDelay = 0; 106 this._display.queryParserVisible = true; 107 this._display.setHistorySettings({useBrowserHistory: true}); 108 109 this._searchButton.addEventListener('click', this._onSearch.bind(this), false); 110 this._clearButton.addEventListener('click', this._onClear.bind(this), false); 111 112 this._searchBackButton.addEventListener('click', this._onSearchBackButtonClick.bind(this), false); 113 this._wanakanaEnableCheckbox.addEventListener('change', this._onWanakanaEnableChange.bind(this)); 114 window.addEventListener('copy', this._onCopy.bind(this)); 115 window.addEventListener('paste', this._onPaste.bind(this)); 116 this._clipboardMonitor.on('change', this._onClipboardMonitorChange.bind(this)); 117 this._clipboardMonitorEnableCheckbox.addEventListener('change', this._onClipboardMonitorEnableChange.bind(this)); 118 this._stickyHeaderEnableCheckbox.addEventListener('change', this._onStickyHeaderEnableChange.bind(this)); 119 this._display.hotkeyHandler.on('keydownNonHotkey', this._onKeyDown.bind(this)); 120 121 this._profileSelect.addEventListener('change', this._onProfileSelectChange.bind(this), false); 122 123 const displayOptions = this._display.getOptions(); 124 if (displayOptions !== null) { 125 await this._onDisplayOptionsUpdated({options: displayOptions}); 126 } 127 } 128 129 /** 130 * @param {import('display').SearchMode} mode 131 */ 132 setMode(mode) { 133 this._searchPersistentStateController.mode = mode; 134 } 135 136 // Actions 137 138 /** */ 139 _onActionFocusSearchBox() { 140 if (this._queryInput === null) { return; } 141 this._queryInput.focus(); 142 this._queryInput.select(); 143 } 144 145 // Messages 146 147 /** @type {import('application').ApiHandler<'searchDisplayControllerSetMode'>} */ 148 _onMessageSetMode({mode}) { 149 this.setMode(mode); 150 } 151 152 /** @type {import('application').ApiHandler<'searchDisplayControllerGetMode'>} */ 153 _onMessageGetMode() { 154 return this._searchPersistentStateController.mode; 155 } 156 157 // Private 158 159 /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */ 160 _onMessage({action, params}, _sender, callback) { 161 return invokeApiMapHandler(this._apiMap, action, params, [], callback); 162 } 163 164 /** 165 * @param {KeyboardEvent} e 166 */ 167 _onKeyDown(e) { 168 const activeElement = document.activeElement; 169 170 const isInputField = this._isElementInput(activeElement); 171 const isAllowedKey = e.key.length === 1 || e.key === 'Backspace'; 172 const isModifierKey = e.ctrlKey || e.metaKey || e.altKey; 173 const isSpaceKey = e.key === ' '; 174 const isCtrlBackspace = e.ctrlKey && e.key === 'Backspace'; 175 176 if (!isInputField && (!isModifierKey || isCtrlBackspace) && isAllowedKey && !isSpaceKey) { 177 this._queryInput.focus({preventScroll: true}); 178 } 179 180 if (e.ctrlKey && e.key === 'u') { 181 this._onClear(e); 182 } 183 } 184 185 /** */ 186 async _onOptionsUpdated() { 187 await this._display.updateOptions(); 188 const query = this._queryInput.value; 189 if (query) { 190 this._display.searchLast(false); 191 } 192 } 193 194 /** 195 * @param {import('display').EventArgument<'optionsUpdated'>} details 196 */ 197 async _onDisplayOptionsUpdated({options}) { 198 this._clipboardMonitorEnabled = options.clipboard.enableSearchPageMonitor; 199 this._updateClipboardMonitorEnabled(); 200 this._updateSearchSettings(options); 201 this._queryInput.lang = options.general.language; 202 await this._updateProfileSelect(); 203 } 204 205 /** 206 * @param {import('settings').ProfileOptions} options 207 */ 208 _updateSearchSettings(options) { 209 const {language, enableWanakana, stickySearchHeader} = options.general; 210 const wanakanaEnabled = language === 'ja' && enableWanakana; 211 this._wanakanaEnableCheckbox.checked = wanakanaEnabled; 212 this._wanakanaSearchOption.style.display = language === 'ja' ? '' : 'none'; 213 this._setWanakanaEnabled(wanakanaEnabled); 214 this._setStickyHeaderEnabled(stickySearchHeader); 215 } 216 217 /** 218 * @param {import('display').EventArgument<'contentUpdateStart'>} details 219 */ 220 _onContentUpdateStart({type, query}) { 221 let animate = false; 222 let valid = false; 223 let showBackButton = false; 224 switch (type) { 225 case 'terms': 226 case 'kanji': 227 { 228 const {content, state} = this._display.history; 229 animate = (typeof content === 'object' && content !== null && content.animate === true); 230 showBackButton = (typeof state === 'object' && state !== null && state.cause === 'queryParser'); 231 valid = (typeof query === 'string' && query.length > 0); 232 this._display.blurElement(this._queryInput); 233 } 234 break; 235 case 'clear': 236 valid = false; 237 animate = true; 238 query = ''; 239 break; 240 } 241 242 if (typeof query !== 'string') { query = ''; } 243 244 this._searchBackButton.hidden = !showBackButton; 245 246 if (this._queryInput.value !== query) { 247 this._queryInput.value = query.trimEnd(); 248 this._updateSearchHeight(true); 249 } 250 this._setIntroVisible(!valid, animate); 251 } 252 253 /** 254 * @param {InputEvent} e 255 */ 256 _onSearchInput(e) { 257 this._updateSearchHeight(true); 258 259 const element = /** @type {HTMLTextAreaElement} */ (e.currentTarget); 260 if (this._wanakanaEnabled) { 261 this._searchTextKanaConversion(element, e); 262 } 263 } 264 265 /** 266 * @param {HTMLTextAreaElement} element 267 * @param {InputEvent} event 268 */ 269 _searchTextKanaConversion(element, event) { 270 const platform = document.documentElement.dataset.platform ?? 'unknown'; 271 const browser = document.documentElement.dataset.browser ?? 'unknown'; 272 if (isComposing(event, platform, browser)) { return; } 273 const {kanaString, newSelectionStart} = convertToKanaIME(element.value, element.selectionStart); 274 element.value = kanaString; 275 element.setSelectionRange(newSelectionStart, newSelectionStart); 276 } 277 278 /** 279 * @param {KeyboardEvent} e 280 */ 281 _onSearchKeydown(e) { 282 // Keycode 229 is a special value for events processed by the IME. 283 // https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event#keydown_events_with_ime 284 if (e.isComposing || e.keyCode === 229) { return; } 285 const {code, key} = e; 286 if (!((code === 'Enter' || key === 'Enter' || code === 'NumpadEnter') && !e.shiftKey)) { return; } 287 288 // Search 289 const element = /** @type {HTMLElement} */ (e.currentTarget); 290 e.preventDefault(); 291 e.stopImmediatePropagation(); 292 this._display.blurElement(element); 293 this._search(true, 'new', true, null); 294 } 295 296 /** 297 * @param {MouseEvent} e 298 */ 299 _onSearch(e) { 300 e.preventDefault(); 301 this._search(true, 'new', true, null); 302 } 303 304 /** 305 * @param {Event} e 306 */ 307 _onClear(e) { 308 e.preventDefault(); 309 this._queryInput.value = ''; 310 this._queryInput.focus(); 311 this._updateSearchHeight(true); 312 } 313 314 /** */ 315 _onSearchBackButtonClick() { 316 this._display.history.back(); 317 } 318 319 /** */ 320 async _onCopy() { 321 // Ignore copy from search page 322 this._clipboardMonitor.setPreviousText(document.hasFocus() ? await this._clipboardReaderLike.getText(false) : ''); 323 } 324 325 /** 326 * @param {ClipboardEvent} e 327 */ 328 _onPaste(e) { 329 if (e.target === this._queryInput) { 330 return; 331 } 332 e.stopPropagation(); 333 e.preventDefault(); 334 const text = e.clipboardData?.getData('text'); 335 if (!text) { 336 return; 337 } 338 if (this._queryInput.value !== text) { 339 this._queryInput.value = text; 340 this._updateSearchHeight(true); 341 this._search(true, 'new', true, null); 342 } 343 } 344 345 /** @type {import('application').ApiHandler<'searchDisplayControllerUpdateSearchQuery'>} */ 346 _onExternalSearchUpdate({text, animate}) { 347 void this._updateSearchFromClipboard(text, animate, false); 348 } 349 350 /** 351 * @param {import('clipboard-monitor').Events['change']} event 352 */ 353 _onClipboardMonitorChange({text}) { 354 void this._updateSearchFromClipboard(text, true, true); 355 } 356 357 /** 358 * @param {string} text 359 * @param {boolean} animate 360 * @param {boolean} checkText 361 */ 362 async _updateSearchFromClipboard(text, animate, checkText) { 363 const options = this._display.getOptions(); 364 if (options === null) { return; } 365 if (checkText && !await this._display.application.api.isTextLookupWorthy(text, options.general.language)) { return; } 366 const {clipboard: {autoSearchContent, maximumSearchLength}} = options; 367 if (text.length > maximumSearchLength) { 368 text = text.substring(0, maximumSearchLength); 369 } 370 this._queryInput.value = text; 371 this._updateSearchHeight(true); 372 this._search(animate, 'clear', autoSearchContent, ['clipboard']); 373 } 374 375 /** 376 * @param {Event} e 377 */ 378 _onWanakanaEnableChange(e) { 379 const element = /** @type {HTMLInputElement} */ (e.target); 380 const value = element.checked; 381 this._setWanakanaEnabled(value); 382 /** @type {import('settings-modifications').ScopedModificationSet} */ 383 const modification = { 384 action: 'set', 385 path: 'general.enableWanakana', 386 value, 387 scope: 'profile', 388 optionsContext: this._display.getOptionsContext(), 389 }; 390 void this._display.application.api.modifySettings([modification], 'search'); 391 } 392 393 /** 394 * @param {Event} e 395 */ 396 _onClipboardMonitorEnableChange(e) { 397 const element = /** @type {HTMLInputElement} */ (e.target); 398 const enabled = element.checked; 399 void this._setClipboardMonitorEnabled(enabled); 400 } 401 402 /** 403 * @param {Event} e 404 */ 405 _onStickyHeaderEnableChange(e) { 406 const element = /** @type {HTMLInputElement} */ (e.target); 407 const value = element.checked; 408 this._setStickyHeaderEnabled(value); 409 /** @type {import('settings-modifications').ScopedModificationSet} */ 410 const modification = { 411 action: 'set', 412 path: 'general.stickySearchHeader', 413 value, 414 scope: 'profile', 415 optionsContext: this._display.getOptionsContext(), 416 }; 417 void this._display.application.api.modifySettings([modification], 'search'); 418 } 419 420 /** 421 * @param {boolean} stickySearchHeaderEnabled 422 */ 423 _setStickyHeaderEnabled(stickySearchHeaderEnabled) { 424 this._stickyHeaderEnableCheckbox.checked = stickySearchHeaderEnabled; 425 } 426 427 /** */ 428 _onModeChange() { 429 this._updateClipboardMonitorEnabled(); 430 } 431 432 /** 433 * @param {Event} event 434 */ 435 async _onProfileSelectChange(event) { 436 const node = /** @type {HTMLInputElement} */ (event.currentTarget); 437 const value = Number.parseInt(node.value, 10); 438 const optionsFull = await this._display.application.api.optionsGetFull(); 439 if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= optionsFull.profiles.length) { 440 await this._setDefaultProfileIndex(value); 441 } 442 } 443 444 /** 445 * @param {number} value 446 */ 447 async _setDefaultProfileIndex(value) { 448 /** @type {import('settings-modifications').ScopedModificationSet} */ 449 const modification = { 450 action: 'set', 451 path: 'profileCurrent', 452 value, 453 scope: 'global', 454 optionsContext: null, 455 }; 456 await this._display.application.api.modifySettings([modification], 'search'); 457 } 458 459 /** 460 * @param {boolean} enabled 461 */ 462 _setWanakanaEnabled(enabled) { 463 if (this._queryInputEventsSetup && this._wanakanaEnabled === enabled) { return; } 464 465 const input = this._queryInput; 466 this._queryInputEvents.removeAllEventListeners(); 467 this._queryInputEvents.addEventListener(input, 'keydown', this._onSearchKeydown.bind(this), false); 468 469 this._wanakanaEnabled = enabled; 470 471 this._queryInputEvents.addEventListener(input, 'input', this._onSearchInput.bind(this), false); 472 this._queryInputEventsSetup = true; 473 } 474 475 /** 476 * @param {boolean} visible 477 * @param {boolean} animate 478 */ 479 _setIntroVisible(visible, animate) { 480 if (this._introVisible === visible) { 481 return; 482 } 483 484 this._introVisible = visible; 485 486 if (this._introElement === null) { 487 return; 488 } 489 490 if (this._introAnimationTimer !== null) { 491 clearTimeout(this._introAnimationTimer); 492 this._introAnimationTimer = null; 493 } 494 495 if (visible) { 496 this._showIntro(animate); 497 } else { 498 this._hideIntro(animate); 499 } 500 } 501 502 /** 503 * @param {boolean} animate 504 */ 505 _showIntro(animate) { 506 if (animate) { 507 const duration = 0.4; 508 this._introElement.style.transition = ''; 509 this._introElement.style.height = ''; 510 const size = this._introElement.getBoundingClientRect(); 511 this._introElement.style.height = '0px'; 512 this._introElement.style.transition = `height ${duration}s ease-in-out 0s`; 513 window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation 514 this._introElement.style.height = `${size.height}px`; 515 this._introAnimationTimer = setTimeout(() => { 516 this._introElement.style.height = ''; 517 this._introAnimationTimer = null; 518 }, duration * 1000); 519 } else { 520 this._introElement.style.transition = ''; 521 this._introElement.style.height = ''; 522 } 523 } 524 525 /** 526 * @param {boolean} animate 527 */ 528 _hideIntro(animate) { 529 if (animate) { 530 const duration = 0.4; 531 const size = this._introElement.getBoundingClientRect(); 532 this._introElement.style.height = `${size.height}px`; 533 this._introElement.style.transition = `height ${duration}s ease-in-out 0s`; 534 window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation 535 } else { 536 this._introElement.style.transition = ''; 537 } 538 this._introElement.style.height = '0'; 539 } 540 541 /** 542 * @param {boolean} value 543 */ 544 async _setClipboardMonitorEnabled(value) { 545 let modify = true; 546 if (value) { 547 value = await this._requestPermissions(['clipboardRead']); 548 modify = value; 549 } 550 551 this._clipboardMonitorEnabled = value; 552 this._updateClipboardMonitorEnabled(); 553 554 if (!modify) { return; } 555 556 /** @type {import('settings-modifications').ScopedModificationSet} */ 557 const modification = { 558 action: 'set', 559 path: 'clipboard.enableSearchPageMonitor', 560 value, 561 scope: 'profile', 562 optionsContext: this._display.getOptionsContext(), 563 }; 564 await this._display.application.api.modifySettings([modification], 'search'); 565 } 566 567 /** */ 568 _updateClipboardMonitorEnabled() { 569 const enabled = this._clipboardMonitorEnabled; 570 this._clipboardMonitorEnableCheckbox.checked = enabled; 571 if (enabled && this._canEnableClipboardMonitor()) { 572 this._clipboardMonitor.start(); 573 } else { 574 this._clipboardMonitor.stop(); 575 } 576 } 577 578 /** 579 * @returns {boolean} 580 */ 581 _canEnableClipboardMonitor() { 582 switch (this._searchPersistentStateController.mode) { 583 case 'action-popup': 584 return false; 585 default: 586 return true; 587 } 588 } 589 590 /** 591 * @param {string[]} permissions 592 * @returns {Promise<boolean>} 593 */ 594 _requestPermissions(permissions) { 595 return new Promise((resolve) => { 596 chrome.permissions.request( 597 {permissions}, 598 (granted) => { 599 const e = chrome.runtime.lastError; 600 resolve(!e && granted); 601 }, 602 ); 603 }); 604 } 605 606 /** 607 * @param {boolean} animate 608 * @param {import('display').HistoryMode} historyMode 609 * @param {boolean} lookup 610 * @param {?import('settings').OptionsContextFlag[]} flags 611 */ 612 _search(animate, historyMode, lookup, flags) { 613 this._updateSearchText(); 614 615 const query = this._queryInput.value; 616 const depth = this._display.depth; 617 const url = window.location.href; 618 const documentTitle = document.title; 619 /** @type {import('settings').OptionsContext} */ 620 const optionsContext = {depth, url}; 621 if (flags !== null) { 622 optionsContext.flags = flags; 623 } 624 const {tabId, frameId} = this._display.application; 625 /** @type {import('display').ContentDetails} */ 626 const details = { 627 focus: false, 628 historyMode, 629 params: { 630 query, 631 }, 632 state: { 633 focusEntry: 0, 634 optionsContext, 635 url, 636 sentence: {text: query, offset: 0}, 637 documentTitle, 638 }, 639 content: { 640 dictionaryEntries: void 0, 641 animate, 642 contentOrigin: {tabId, frameId}, 643 }, 644 }; 645 if (!lookup) { details.params.lookup = 'false'; } 646 this._display.setContent(details); 647 } 648 649 /** 650 * @param {boolean} shrink 651 */ 652 _updateSearchHeight(shrink) { 653 const searchTextbox = this._queryInput; 654 const searchItems = [this._queryInput, this._searchButton, this._searchBackButton, this._clearButton]; 655 656 if (shrink) { 657 for (const searchButton of searchItems) { 658 searchButton.style.height = '0'; 659 } 660 } 661 const {scrollHeight} = searchTextbox; 662 const currentHeight = searchTextbox.getBoundingClientRect().height; 663 if (shrink || scrollHeight >= currentHeight - 1) { 664 for (const searchButton of searchItems) { 665 searchButton.style.height = `${scrollHeight}px`; 666 } 667 } 668 } 669 670 /** */ 671 _updateSearchText() { 672 if (this._wanakanaEnabled) { 673 // don't use convertToKanaIME since user searching has finalized the text and is no longer composing 674 this._queryInput.value = convertToKana(this._queryInput.value); 675 } 676 this._queryInput.setSelectionRange(this._queryInput.value.length, this._queryInput.value.length); 677 } 678 679 /** 680 * @param {?Element} element 681 * @returns {boolean} 682 */ 683 _isElementInput(element) { 684 if (element === null) { return false; } 685 switch (element.tagName.toLowerCase()) { 686 case 'input': 687 case 'textarea': 688 case 'button': 689 case 'select': 690 return true; 691 } 692 return element instanceof HTMLElement && !!element.isContentEditable; 693 } 694 695 /** */ 696 async _updateProfileSelect() { 697 const {profiles, profileCurrent} = await this._display.application.api.optionsGetFull(); 698 699 /** @type {HTMLElement} */ 700 const optionGroup = querySelectorNotNull(document, '#profile-select-option-group'); 701 while (optionGroup.firstChild) { 702 optionGroup.removeChild(optionGroup.firstChild); 703 } 704 705 this._profileSelectContainer.hidden = profiles.length <= 1; 706 707 const fragment = document.createDocumentFragment(); 708 for (let i = 0, ii = profiles.length; i < ii; ++i) { 709 const {name} = profiles[i]; 710 const option = document.createElement('option'); 711 option.textContent = name; 712 option.value = `${i}`; 713 fragment.appendChild(option); 714 } 715 optionGroup.textContent = ''; 716 optionGroup.appendChild(fragment); 717 this._profileSelect.value = `${profileCurrent}`; 718 } 719}