| /* |
| * Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| * Use of this source code is governed by a BSD-style license that can be |
| * found in the LICENSE file. |
| */ |
| /** |
| * @unrestricted |
| * @implements {UI.ListDelegate} |
| */ |
| export class FilteredListWidget extends UI.VBox { |
| /** |
| * @param {?QuickOpen.FilteredListWidget.Provider} provider |
| * @param {!Array<string>=} promptHistory |
| * @param {function(string)=} queryChangedCallback |
| */ |
| constructor(provider, promptHistory, queryChangedCallback) { |
| super(true); |
| this._promptHistory = promptHistory || []; |
| |
| this.contentElement.classList.add('filtered-list-widget'); |
| this.contentElement.addEventListener('keydown', this._onKeyDown.bind(this), true); |
| UI.ARIAUtils.markAsCombobox(this.contentElement); |
| this.registerRequiredCSS('quick_open/filteredListWidget.css'); |
| |
| this._promptElement = this.contentElement.createChild('div', 'filtered-list-widget-input'); |
| UI.ARIAUtils.setAccessibleName(this._promptElement, ls`Quick open prompt`); |
| this._promptElement.setAttribute('spellcheck', 'false'); |
| this._promptElement.setAttribute('contenteditable', 'plaintext-only'); |
| this._prompt = new UI.TextPrompt(); |
| this._prompt.initialize(() => Promise.resolve([])); |
| const promptProxy = this._prompt.attach(this._promptElement); |
| promptProxy.addEventListener('input', this._onInput.bind(this), false); |
| promptProxy.classList.add('filtered-list-widget-prompt-element'); |
| |
| this._bottomElementsContainer = this.contentElement.createChild('div', 'vbox'); |
| this._progressElement = this._bottomElementsContainer.createChild('div', 'filtered-list-widget-progress'); |
| this._progressBarElement = this._progressElement.createChild('div', 'filtered-list-widget-progress-bar'); |
| |
| /** @type {!UI.ListModel<number>} */ |
| this._items = new UI.ListModel(); |
| /** @type {!UI.ListControl<number>} */ |
| this._list = new UI.ListControl(this._items, this, UI.ListMode.EqualHeightItems); |
| this._itemElementsContainer = this._list.element; |
| this._itemElementsContainer.classList.add('container'); |
| this._bottomElementsContainer.appendChild(this._itemElementsContainer); |
| this._itemElementsContainer.addEventListener('click', this._onClick.bind(this), false); |
| UI.ARIAUtils.markAsListBox(this._itemElementsContainer); |
| UI.ARIAUtils.setControls(this._promptElement, this._itemElementsContainer); |
| UI.ARIAUtils.setAutocomplete(this._promptElement, UI.ARIAUtils.AutocompleteInteractionModel.list); |
| |
| this._notFoundElement = this._bottomElementsContainer.createChild('div', 'not-found-text'); |
| this._notFoundElement.classList.add('hidden'); |
| |
| this.setDefaultFocusedElement(this._promptElement); |
| |
| this._prefix = ''; |
| this._provider = provider; |
| this._queryChangedCallback = queryChangedCallback; |
| } |
| |
| /** |
| * @param {!Element} element |
| * @param {string} query |
| * @param {boolean=} caseInsensitive |
| * @return {boolean} |
| */ |
| static highlightRanges(element, query, caseInsensitive) { |
| if (!query) { |
| return false; |
| } |
| |
| /** |
| * @param {string} text |
| * @param {string} query |
| * @return {?Array.<!TextUtils.SourceRange>} |
| */ |
| function rangesForMatch(text, query) { |
| const opcodes = Diff.Diff.charDiff(query, text); |
| let offset = 0; |
| const ranges = []; |
| for (let i = 0; i < opcodes.length; ++i) { |
| const opcode = opcodes[i]; |
| if (opcode[0] === Diff.Diff.Operation.Equal) { |
| ranges.push(new TextUtils.SourceRange(offset, opcode[1].length)); |
| } else if (opcode[0] !== Diff.Diff.Operation.Insert) { |
| return null; |
| } |
| offset += opcode[1].length; |
| } |
| return ranges; |
| } |
| |
| const text = element.textContent; |
| let ranges = rangesForMatch(text, query); |
| if (!ranges || caseInsensitive) { |
| ranges = rangesForMatch(text.toUpperCase(), query.toUpperCase()); |
| } |
| if (ranges) { |
| UI.highlightRangesWithStyleClass(element, ranges, 'highlight'); |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * @param {string} placeholder |
| * @param {string=} ariaPlaceholder |
| */ |
| setPlaceholder(placeholder, ariaPlaceholder) { |
| this._prompt.setPlaceholder(placeholder, ariaPlaceholder); |
| } |
| |
| showAsDialog() { |
| this._dialog = new UI.Dialog(); |
| UI.ARIAUtils.setAccessibleName(this._dialog.contentElement, ls`Quick open`); |
| this._dialog.setMaxContentSize(new UI.Size(504, 340)); |
| this._dialog.setSizeBehavior(UI.GlassPane.SizeBehavior.SetExactWidthMaxHeight); |
| this._dialog.setContentPosition(null, 22); |
| this.show(this._dialog.contentElement); |
| UI.ARIAUtils.setExpanded(this.contentElement, true); |
| this._dialog.show(); |
| } |
| |
| /** |
| * @param {string} prefix |
| */ |
| setPrefix(prefix) { |
| this._prefix = prefix; |
| } |
| |
| /** |
| * @param {?QuickOpen.FilteredListWidget.Provider} provider |
| */ |
| setProvider(provider) { |
| if (provider === this._provider) { |
| return; |
| } |
| |
| if (this._provider) { |
| this._provider.detach(); |
| } |
| this._clearTimers(); |
| |
| this._provider = provider; |
| if (this.isShowing()) { |
| this._attachProvider(); |
| } |
| } |
| |
| _attachProvider() { |
| this._items.replaceAll([]); |
| this._list.invalidateItemHeight(); |
| if (this._provider) { |
| this._provider.setRefreshCallback(this._itemsLoaded.bind(this, this._provider)); |
| this._provider.attach(); |
| } |
| this._itemsLoaded(this._provider); |
| } |
| |
| /** |
| * @return {string} |
| */ |
| _value() { |
| return this._prompt.text().trim(); |
| } |
| |
| _cleanValue() { |
| return this._value().substring(this._prefix.length); |
| } |
| |
| /** |
| * @override |
| */ |
| wasShown() { |
| this._attachProvider(); |
| } |
| |
| /** |
| * @override |
| */ |
| willHide() { |
| if (this._provider) { |
| this._provider.detach(); |
| } |
| this._clearTimers(); |
| UI.ARIAUtils.setExpanded(this.contentElement, false); |
| } |
| |
| _clearTimers() { |
| clearTimeout(this._filterTimer); |
| clearTimeout(this._scoringTimer); |
| clearTimeout(this._loadTimeout); |
| delete this._filterTimer; |
| delete this._scoringTimer; |
| delete this._loadTimeout; |
| delete this._refreshListWithCurrentResult; |
| } |
| |
| /** |
| * @param {!Event} event |
| */ |
| _onEnter(event) { |
| if (!this._provider) { |
| return; |
| } |
| const selectedIndexInProvider = this._provider.itemCount() ? this._list.selectedItem() : null; |
| |
| this._selectItem(selectedIndexInProvider); |
| if (this._dialog) { |
| this._dialog.hide(); |
| } |
| } |
| |
| /** |
| * @param {?QuickOpen.FilteredListWidget.Provider} provider |
| */ |
| _itemsLoaded(provider) { |
| if (this._loadTimeout || provider !== this._provider) { |
| return; |
| } |
| this._loadTimeout = setTimeout(this._updateAfterItemsLoaded.bind(this), 0); |
| } |
| |
| _updateAfterItemsLoaded() { |
| delete this._loadTimeout; |
| this._filterItems(); |
| } |
| |
| /** |
| * @override |
| * @param {number} item |
| * @return {!Element} |
| */ |
| createElementForItem(item) { |
| const itemElement = createElement('div'); |
| itemElement.className = 'filtered-list-widget-item ' + (this._provider.renderAsTwoRows() ? 'two-rows' : 'one-row'); |
| const titleElement = itemElement.createChild('div', 'filtered-list-widget-title'); |
| const subtitleElement = itemElement.createChild('div', 'filtered-list-widget-subtitle'); |
| subtitleElement.textContent = '\u200B'; |
| this._provider.renderItem(item, this._cleanValue(), titleElement, subtitleElement); |
| UI.ARIAUtils.markAsOption(itemElement); |
| return itemElement; |
| } |
| |
| /** |
| * @override |
| * @param {number} item |
| * @return {number} |
| */ |
| heightForItem(item) { |
| // Let the list measure items for us. |
| return 0; |
| } |
| |
| /** |
| * @override |
| * @param {number} item |
| * @return {boolean} |
| */ |
| isItemSelectable(item) { |
| return true; |
| } |
| |
| /** |
| * @override |
| * @param {?number} from |
| * @param {?number} to |
| * @param {?Element} fromElement |
| * @param {?Element} toElement |
| */ |
| selectedItemChanged(from, to, fromElement, toElement) { |
| if (fromElement) { |
| fromElement.classList.remove('selected'); |
| } |
| if (toElement) { |
| toElement.classList.add('selected'); |
| } |
| UI.ARIAUtils.setActiveDescendant(this._promptElement, toElement); |
| } |
| |
| /** |
| * @param {!Event} event |
| */ |
| _onClick(event) { |
| const item = this._list.itemForNode(/** @type {?Node} */ (event.target)); |
| if (item === null) { |
| return; |
| } |
| |
| event.consume(true); |
| this._selectItem(item); |
| if (this._dialog) { |
| this._dialog.hide(); |
| } |
| } |
| |
| /** |
| * @param {string} query |
| */ |
| setQuery(query) { |
| this._prompt.focus(); |
| this._prompt.setText(query); |
| this._queryChanged(); |
| this._prompt.autoCompleteSoon(true); |
| this._scheduleFilter(); |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| _tabKeyPressed() { |
| const userEnteredText = this._prompt.text(); |
| let completion; |
| for (let i = this._promptHistory.length - 1; i >= 0; i--) { |
| if (this._promptHistory[i] !== userEnteredText && this._promptHistory[i].startsWith(userEnteredText)) { |
| completion = this._promptHistory[i]; |
| break; |
| } |
| } |
| if (!completion) { |
| return false; |
| } |
| this._prompt.focus(); |
| this._prompt.setText(completion); |
| this._prompt.setDOMSelection(userEnteredText.length, completion.length); |
| this._scheduleFilter(); |
| return true; |
| } |
| |
| _itemsFilteredForTest() { |
| // Sniffed in tests. |
| } |
| |
| _filterItems() { |
| delete this._filterTimer; |
| if (this._scoringTimer) { |
| clearTimeout(this._scoringTimer); |
| delete this._scoringTimer; |
| |
| if (this._refreshListWithCurrentResult) { |
| this._refreshListWithCurrentResult(); |
| } |
| } |
| |
| if (!this._provider) { |
| this._bottomElementsContainer.classList.toggle('hidden', true); |
| this._itemsFilteredForTest(); |
| return; |
| } |
| |
| this._bottomElementsContainer.classList.toggle('hidden', false); |
| |
| this._progressBarElement.style.transform = 'scaleX(0)'; |
| this._progressBarElement.classList.remove('filtered-widget-progress-fade'); |
| this._progressBarElement.classList.remove('hidden'); |
| |
| const query = this._provider.rewriteQuery(this._cleanValue()); |
| this._query = query; |
| |
| const filterRegex = query ? String.filterRegex(query) : null; |
| |
| const filteredItems = []; |
| |
| const bestScores = []; |
| const bestItems = []; |
| const bestItemsToCollect = 100; |
| let minBestScore = 0; |
| const overflowItems = []; |
| const scoreStartTime = window.performance.now(); |
| |
| const maxWorkItems = Number.constrain(10, 500, (this._provider.itemCount() / 10) | 0); |
| |
| scoreItems.call(this, 0); |
| |
| /** |
| * @param {number} a |
| * @param {number} b |
| * @return {number} |
| */ |
| function compareIntegers(a, b) { |
| return b - a; |
| } |
| |
| /** |
| * @param {number} fromIndex |
| * @this {QuickOpen.FilteredListWidget} |
| */ |
| function scoreItems(fromIndex) { |
| delete this._scoringTimer; |
| let workDone = 0; |
| let i; |
| |
| for (i = fromIndex; i < this._provider.itemCount() && workDone < maxWorkItems; ++i) { |
| // Filter out non-matching items quickly. |
| if (filterRegex && !filterRegex.test(this._provider.itemKeyAt(i))) { |
| continue; |
| } |
| |
| // Score item. |
| const score = this._provider.itemScoreAt(i, query); |
| if (query) { |
| workDone++; |
| } |
| |
| // Find its index in the scores array (earlier elements have bigger scores). |
| if (score > minBestScore || bestScores.length < bestItemsToCollect) { |
| const index = bestScores.upperBound(score, compareIntegers); |
| bestScores.splice(index, 0, score); |
| bestItems.splice(index, 0, i); |
| if (bestScores.length > bestItemsToCollect) { |
| // Best list is too large -> drop last elements. |
| overflowItems.push(bestItems.peekLast()); |
| bestScores.length = bestItemsToCollect; |
| bestItems.length = bestItemsToCollect; |
| } |
| minBestScore = bestScores.peekLast(); |
| } else { |
| filteredItems.push(i); |
| } |
| } |
| |
| this._refreshListWithCurrentResult = this._refreshList.bind(this, bestItems, overflowItems, filteredItems); |
| |
| // Process everything in chunks. |
| if (i < this._provider.itemCount()) { |
| this._scoringTimer = setTimeout(scoreItems.bind(this, i), 0); |
| if (window.performance.now() - scoreStartTime > 50) { |
| this._progressBarElement.style.transform = 'scaleX(' + i / this._provider.itemCount() + ')'; |
| } |
| return; |
| } |
| if (window.performance.now() - scoreStartTime > 100) { |
| this._progressBarElement.style.transform = 'scaleX(1)'; |
| this._progressBarElement.classList.add('filtered-widget-progress-fade'); |
| } else { |
| this._progressBarElement.classList.add('hidden'); |
| } |
| this._refreshListWithCurrentResult(); |
| } |
| } |
| |
| /** |
| * @param {!Array<number>} bestItems |
| * @param {!Array<number>} overflowItems |
| * @param {!Array<number>} filteredItems |
| */ |
| _refreshList(bestItems, overflowItems, filteredItems) { |
| delete this._refreshListWithCurrentResult; |
| filteredItems = [].concat(bestItems, overflowItems, filteredItems); |
| this._updateNotFoundMessage(!!filteredItems.length); |
| const oldHeight = this._list.element.offsetHeight; |
| this._items.replaceAll(filteredItems); |
| if (filteredItems.length) { |
| this._list.selectItem(filteredItems[0]); |
| } |
| if (this._list.element.offsetHeight !== oldHeight) { |
| this._list.viewportResized(); |
| } |
| this._itemsFilteredForTest(); |
| } |
| |
| /** |
| * @param {boolean} hasItems |
| */ |
| _updateNotFoundMessage(hasItems) { |
| this._list.element.classList.toggle('hidden', !hasItems); |
| this._notFoundElement.classList.toggle('hidden', hasItems); |
| if (!hasItems) { |
| this._notFoundElement.textContent = this._provider.notFoundText(this._cleanValue()); |
| UI.ARIAUtils.alert(this._notFoundElement.textContent, this._notFoundElement); |
| } |
| } |
| |
| _onInput() { |
| this._queryChanged(); |
| this._scheduleFilter(); |
| } |
| |
| _queryChanged() { |
| if (this._queryChangedCallback) { |
| this._queryChangedCallback(this._value()); |
| } |
| if (this._provider) { |
| this._provider.queryChanged(this._cleanValue()); |
| } |
| } |
| |
| /** |
| * @override |
| * @param {?Element} fromElement |
| * @param {?Element} toElement |
| * @return {boolean} |
| */ |
| updateSelectedItemARIA(fromElement, toElement) { |
| return false; |
| } |
| |
| /** |
| * @param {!Event} event |
| */ |
| _onKeyDown(event) { |
| let handled = false; |
| switch (event.key) { |
| case 'Enter': |
| this._onEnter(event); |
| return; |
| case 'Tab': |
| handled = this._tabKeyPressed(); |
| break; |
| case 'ArrowUp': |
| handled = this._list.selectPreviousItem(true, false); |
| break; |
| case 'ArrowDown': |
| handled = this._list.selectNextItem(true, false); |
| break; |
| case 'PageUp': |
| handled = this._list.selectItemPreviousPage(false); |
| break; |
| case 'PageDown': |
| handled = this._list.selectItemNextPage(false); |
| break; |
| } |
| if (handled) { |
| event.consume(true); |
| } |
| } |
| |
| _scheduleFilter() { |
| if (this._filterTimer) { |
| return; |
| } |
| this._filterTimer = setTimeout(this._filterItems.bind(this), 0); |
| } |
| |
| /** |
| * @param {?number} itemIndex |
| */ |
| _selectItem(itemIndex) { |
| this._promptHistory.push(this._value()); |
| if (this._promptHistory.length > 100) { |
| this._promptHistory.shift(); |
| } |
| this._provider.selectItem(itemIndex, this._cleanValue()); |
| } |
| } |
| |
| |
| /** |
| * @unrestricted |
| */ |
| export class Provider { |
| /** |
| * @param {function():void} refreshCallback |
| */ |
| setRefreshCallback(refreshCallback) { |
| this._refreshCallback = refreshCallback; |
| } |
| |
| attach() { |
| } |
| |
| /** |
| * @return {number} |
| */ |
| itemCount() { |
| return 0; |
| } |
| |
| /** |
| * @param {number} itemIndex |
| * @return {string} |
| */ |
| itemKeyAt(itemIndex) { |
| return ''; |
| } |
| |
| /** |
| * @param {number} itemIndex |
| * @param {string} query |
| * @return {number} |
| */ |
| itemScoreAt(itemIndex, query) { |
| return 1; |
| } |
| |
| /** |
| * @param {number} itemIndex |
| * @param {string} query |
| * @param {!Element} titleElement |
| * @param {!Element} subtitleElement |
| */ |
| renderItem(itemIndex, query, titleElement, subtitleElement) { |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| renderAsTwoRows() { |
| return false; |
| } |
| |
| /** |
| * @param {?number} itemIndex |
| * @param {string} promptValue |
| */ |
| selectItem(itemIndex, promptValue) { |
| } |
| |
| refresh() { |
| this._refreshCallback(); |
| } |
| |
| /** |
| * @param {string} query |
| * @return {string} |
| */ |
| rewriteQuery(query) { |
| return query; |
| } |
| |
| /** |
| * @param {string} query |
| */ |
| queryChanged(query) { |
| } |
| |
| /** |
| * @param {string} query |
| * @return {string} |
| */ |
| notFoundText(query) { |
| return Common.UIString('No results found'); |
| } |
| |
| detach() { |
| } |
| } |
| |
| /* Legacy exported object */ |
| self.QuickOpen = self.QuickOpen || {}; |
| |
| /* Legacy exported object */ |
| QuickOpen = QuickOpen || {}; |
| |
| /** |
| * @constructor |
| */ |
| QuickOpen.FilteredListWidget = FilteredListWidget; |
| |
| /** |
| * @constructor |
| */ |
| QuickOpen.FilteredListWidget.Provider = Provider; |