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