mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-09 13:18:29 +08:00
R=szuend@chromium.org Bug: 1050549 Change-Id: I084877860b71f086bccd500cea1c3ef77a37f615 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2631116 Commit-Queue: Tim van der Lippe <tvanderlippe@chromium.org> Auto-Submit: Tim van der Lippe <tvanderlippe@chromium.org> Reviewed-by: Simon Zünd <szuend@chromium.org>
442 lines
14 KiB
JavaScript
442 lines
14 KiB
JavaScript
/*
|
|
* Copyright (C) 2013 Google Inc. All rights reserved.
|
|
*
|
|
* Redistribution and use in source and binary forms, with or without
|
|
* modification, are permitted provided that the following conditions are
|
|
* met:
|
|
*
|
|
* * Redistributions of source code must retain the above copyright
|
|
* notice, this list of conditions and the following disclaimer.
|
|
* * Redistributions in binary form must reproduce the above
|
|
* copyright notice, this list of conditions and the following disclaimer
|
|
* in the documentation and/or other materials provided with the
|
|
* distribution.
|
|
* * Neither the name of Google Inc. nor the names of its
|
|
* contributors may be used to endorse or promote products derived from
|
|
* this software without specific prior written permission.
|
|
*
|
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
*/
|
|
|
|
import * as Platform from '../platform/platform.js';
|
|
import {ls} from '../platform/platform.js';
|
|
import * as TextUtils from '../text_utils/text_utils.js'; // eslint-disable-line no-unused-vars
|
|
|
|
import * as ARIAUtils from './ARIAUtils.js';
|
|
import {Size} from './Geometry.js';
|
|
import {AnchorBehavior, GlassPane} from './GlassPane.js';
|
|
import {Icon} from './Icon.js';
|
|
import {ListControl, ListDelegate, ListMode} from './ListControl.js'; // eslint-disable-line no-unused-vars
|
|
import {ListModel} from './ListModel.js';
|
|
import {measurePreferredSize} from './UIUtils.js';
|
|
import {createShadowRootWithCoreStyles} from './utils/create-shadow-root-with-core-styles.js';
|
|
import {measuredScrollbarWidth} from './utils/measured-scrollbar-width.js';
|
|
|
|
/**
|
|
* @interface
|
|
*/
|
|
export class SuggestBoxDelegate {
|
|
/**
|
|
* @param {?Suggestion} suggestion
|
|
* @param {boolean=} isIntermediateSuggestion
|
|
*/
|
|
applySuggestion(suggestion, isIntermediateSuggestion) {
|
|
}
|
|
|
|
/**
|
|
* acceptSuggestion will be always called after call to applySuggestion with isIntermediateSuggestion being equal to false.
|
|
*/
|
|
acceptSuggestion() {
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @implements {ListDelegate<!Suggestion>}
|
|
*/
|
|
export class SuggestBox {
|
|
/**
|
|
* @param {!SuggestBoxDelegate} suggestBoxDelegate
|
|
* @param {number=} maxItemsHeight
|
|
*/
|
|
constructor(suggestBoxDelegate, maxItemsHeight) {
|
|
this._suggestBoxDelegate = suggestBoxDelegate;
|
|
this._maxItemsHeight = maxItemsHeight;
|
|
this._rowHeight = 17;
|
|
this._userEnteredText = '';
|
|
this._defaultSelectionIsDimmed = false;
|
|
|
|
/** @type {?Suggestion} */
|
|
this._onlyCompletion = null;
|
|
|
|
/** @type {!ListModel<!Suggestion>} */
|
|
this._items = new ListModel();
|
|
/** @type {!ListControl<!Suggestion>} */
|
|
this._list = new ListControl(this._items, this, ListMode.EqualHeightItems);
|
|
this._element = this._list.element;
|
|
this._element.classList.add('suggest-box');
|
|
this._element.addEventListener('mousedown', event => event.preventDefault(), true);
|
|
this._element.addEventListener('click', this._onClick.bind(this), false);
|
|
|
|
this._glassPane = new GlassPane();
|
|
this._glassPane.setAnchorBehavior(AnchorBehavior.PreferBottom);
|
|
this._glassPane.setOutsideClickCallback(this.hide.bind(this));
|
|
const shadowRoot = createShadowRootWithCoreStyles(
|
|
this._glassPane.contentElement,
|
|
{cssFile: 'ui/suggestBox.css', enableLegacyPatching: true, delegatesFocus: undefined});
|
|
shadowRoot.appendChild(this._element);
|
|
}
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
visible() {
|
|
return this._glassPane.isShowing();
|
|
}
|
|
|
|
/**
|
|
* @param {!AnchorBox} anchorBox
|
|
*/
|
|
setPosition(anchorBox) {
|
|
this._glassPane.setContentAnchorBox(anchorBox);
|
|
}
|
|
|
|
/**
|
|
* @param {!AnchorBehavior} behavior
|
|
*/
|
|
setAnchorBehavior(behavior) {
|
|
this._glassPane.setAnchorBehavior(behavior);
|
|
}
|
|
|
|
/**
|
|
* @param {!Suggestions} items
|
|
*/
|
|
_updateMaxSize(items) {
|
|
const maxWidth = this._maxWidth(items);
|
|
const length = this._maxItemsHeight ? Math.min(this._maxItemsHeight, items.length) : items.length;
|
|
const maxHeight = length * this._rowHeight;
|
|
this._glassPane.setMaxContentSize(new Size(maxWidth, maxHeight));
|
|
}
|
|
|
|
/**
|
|
* @param {!Suggestions} items
|
|
* @return {number}
|
|
*/
|
|
_maxWidth(items) {
|
|
const kMaxWidth = 300;
|
|
if (!items.length) {
|
|
return kMaxWidth;
|
|
}
|
|
let maxItem;
|
|
let maxLength = -Infinity;
|
|
for (let i = 0; i < items.length; i++) {
|
|
const length = (items[i].title || items[i].text).length + (items[i].subtitle || '').length;
|
|
if (length > maxLength) {
|
|
maxLength = length;
|
|
maxItem = items[i];
|
|
}
|
|
}
|
|
const element = this.createElementForItem(/** @type {!Suggestion} */ (maxItem));
|
|
const preferredWidth =
|
|
measurePreferredSize(element, this._element).width + measuredScrollbarWidth(this._element.ownerDocument);
|
|
return Math.min(kMaxWidth, preferredWidth);
|
|
}
|
|
_show() {
|
|
if (this.visible()) {
|
|
return;
|
|
}
|
|
// TODO(dgozman): take document as a parameter.
|
|
this._glassPane.show(document);
|
|
const suggestion = /** @type {!Suggestion} */ ({text: '1', subtitle: '12'});
|
|
this._rowHeight = measurePreferredSize(this.createElementForItem(suggestion), this._element).height;
|
|
}
|
|
|
|
hide() {
|
|
if (!this.visible()) {
|
|
return;
|
|
}
|
|
this._glassPane.hide();
|
|
}
|
|
|
|
/**
|
|
* @param {boolean=} isIntermediateSuggestion
|
|
* @return {boolean}
|
|
*/
|
|
_applySuggestion(isIntermediateSuggestion) {
|
|
if (this._onlyCompletion) {
|
|
ARIAUtils.alert(
|
|
ls`${this._onlyCompletion.text}, suggestion ${this._list.selectedIndex() + 1} of ${this._items.length}`,
|
|
this._element);
|
|
this._suggestBoxDelegate.applySuggestion(this._onlyCompletion, isIntermediateSuggestion);
|
|
return true;
|
|
}
|
|
const suggestion = this._list.selectedItem();
|
|
if (suggestion && suggestion.text) {
|
|
ARIAUtils.alert(
|
|
ls`${suggestion.title || suggestion.text}, suggestion ${this._list.selectedIndex() + 1} of ${
|
|
this._items.length}`,
|
|
this._element);
|
|
}
|
|
this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion);
|
|
|
|
return this.visible() && Boolean(suggestion);
|
|
}
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
acceptSuggestion() {
|
|
const result = this._applySuggestion();
|
|
this.hide();
|
|
if (!result) {
|
|
return false;
|
|
}
|
|
|
|
this._suggestBoxDelegate.acceptSuggestion();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @param {!Suggestion} item
|
|
* @return {!Element}
|
|
*/
|
|
createElementForItem(item) {
|
|
const query = this._userEnteredText;
|
|
const element = document.createElement('div');
|
|
element.classList.add('suggest-box-content-item');
|
|
element.classList.add('source-code');
|
|
if (item.iconType) {
|
|
const icon = Icon.create(item.iconType, 'suggestion-icon');
|
|
element.appendChild(icon);
|
|
}
|
|
if (item.isSecondary) {
|
|
element.classList.add('secondary');
|
|
}
|
|
element.tabIndex = -1;
|
|
const maxTextLength = 50 + query.length;
|
|
const displayText = Platform.StringUtilities.trimEndWithMaxLength((item.title || item.text).trim(), maxTextLength)
|
|
.replace(/\n/g, '\u21B5');
|
|
|
|
const titleElement = element.createChild('span', 'suggestion-title');
|
|
const index = displayText.toLowerCase().indexOf(query.toLowerCase());
|
|
if (index > 0) {
|
|
titleElement.createChild('span').textContent = displayText.substring(0, index);
|
|
}
|
|
if (index > -1) {
|
|
titleElement.createChild('span', 'query').textContent = displayText.substring(index, index + query.length);
|
|
}
|
|
titleElement.createChild('span').textContent = displayText.substring(index > -1 ? index + query.length : 0);
|
|
titleElement.createChild('span', 'spacer');
|
|
if (item.subtitleRenderer) {
|
|
const subtitleElement = /** @type {!HTMLElement} */ (item.subtitleRenderer.call(null));
|
|
subtitleElement.classList.add('suggestion-subtitle');
|
|
element.appendChild(subtitleElement);
|
|
} else if (item.subtitle) {
|
|
const subtitleElement = element.createChild('span', 'suggestion-subtitle');
|
|
subtitleElement.textContent =
|
|
Platform.StringUtilities.trimEndWithMaxLength(item.subtitle, maxTextLength - displayText.length);
|
|
}
|
|
if (item.iconElement) {
|
|
element.appendChild(item.iconElement);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @param {!Suggestion} item
|
|
* @return {number}
|
|
*/
|
|
heightForItem(item) {
|
|
return this._rowHeight;
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @param {!Suggestion} item
|
|
* @return {boolean}
|
|
*/
|
|
isItemSelectable(item) {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @param {?Suggestion} from
|
|
* @param {?Suggestion} to
|
|
* @param {?Element} fromElement
|
|
* @param {?Element} toElement
|
|
*/
|
|
selectedItemChanged(from, to, fromElement, toElement) {
|
|
if (fromElement) {
|
|
fromElement.classList.remove('selected', 'force-white-icons');
|
|
}
|
|
if (toElement) {
|
|
toElement.classList.add('selected');
|
|
toElement.classList.add('force-white-icons');
|
|
}
|
|
this._applySuggestion(true);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @param {?Element} fromElement
|
|
* @param {?Element} toElement
|
|
* @return {boolean}
|
|
*/
|
|
updateSelectedItemARIA(fromElement, toElement) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param {!Event} event
|
|
*/
|
|
_onClick(event) {
|
|
const item = this._list.itemForNode(/** @type {?Node} */ (event.target));
|
|
if (!item) {
|
|
return;
|
|
}
|
|
|
|
this._list.selectItem(item);
|
|
this.acceptSuggestion();
|
|
event.consume(true);
|
|
}
|
|
|
|
/**
|
|
* @param {!Suggestions} completions
|
|
* @param {?Suggestion} highestPriorityItem
|
|
* @param {boolean} canShowForSingleItem
|
|
* @param {string} userEnteredText
|
|
* @return {boolean}
|
|
*/
|
|
_canShowBox(completions, highestPriorityItem, canShowForSingleItem, userEnteredText) {
|
|
if (!completions || !completions.length) {
|
|
return false;
|
|
}
|
|
|
|
if (completions.length > 1) {
|
|
return true;
|
|
}
|
|
|
|
if (!highestPriorityItem || highestPriorityItem.isSecondary ||
|
|
!highestPriorityItem.text.startsWith(userEnteredText)) {
|
|
return true;
|
|
}
|
|
|
|
// Do not show a single suggestion if it is the same as user-entered query, even if allowed to show single-item suggest boxes.
|
|
return canShowForSingleItem && highestPriorityItem.text !== userEnteredText;
|
|
}
|
|
|
|
/**
|
|
* @param {!AnchorBox} anchorBox
|
|
* @param {!Suggestions} completions
|
|
* @param {boolean} selectHighestPriority
|
|
* @param {boolean} canShowForSingleItem
|
|
* @param {string} userEnteredText
|
|
*/
|
|
updateSuggestions(anchorBox, completions, selectHighestPriority, canShowForSingleItem, userEnteredText) {
|
|
this._onlyCompletion = null;
|
|
const highestPriorityItem =
|
|
selectHighestPriority ? completions.reduce((a, b) => (a.priority || 0) >= (b.priority || 0) ? a : b) : null;
|
|
if (this._canShowBox(completions, highestPriorityItem, canShowForSingleItem, userEnteredText)) {
|
|
this._userEnteredText = userEnteredText;
|
|
|
|
this._show();
|
|
this._updateMaxSize(completions);
|
|
this._glassPane.setContentAnchorBox(anchorBox);
|
|
this._list.invalidateItemHeight();
|
|
this._items.replaceAll(completions);
|
|
|
|
if (highestPriorityItem && !highestPriorityItem.isSecondary) {
|
|
this._list.selectItem(highestPriorityItem, true);
|
|
} else {
|
|
this._list.selectItem(null);
|
|
}
|
|
} else {
|
|
if (completions.length === 1) {
|
|
this._onlyCompletion = completions[0];
|
|
this._applySuggestion(true);
|
|
}
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!KeyboardEvent} event
|
|
* @return {boolean}
|
|
*/
|
|
keyPressed(event) {
|
|
switch (event.key) {
|
|
case 'Enter':
|
|
return this.enterKeyPressed();
|
|
case 'ArrowUp':
|
|
return this._list.selectPreviousItem(true, false);
|
|
case 'ArrowDown':
|
|
return this._list.selectNextItem(true, false);
|
|
case 'PageUp':
|
|
return this._list.selectItemPreviousPage(false);
|
|
case 'PageDown':
|
|
return this._list.selectItemNextPage(false);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
enterKeyPressed() {
|
|
const hasSelectedItem = Boolean(this._list.selectedItem()) || Boolean(this._onlyCompletion);
|
|
this.acceptSuggestion();
|
|
|
|
// Report the event as non-handled if there is no selected item,
|
|
// to commit the input or handle it otherwise.
|
|
return hasSelectedItem;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {{
|
|
* text: string,
|
|
* title: (string|undefined),
|
|
* subtitle: (string|undefined),
|
|
* iconType: (string|undefined),
|
|
* priority: (number|undefined),
|
|
* isSecondary: (boolean|undefined),
|
|
* subtitleRenderer: ((function():!Element)|undefined),
|
|
* selectionRange: ({startColumn: number, endColumn: number}|undefined),
|
|
* hideGhostText: (boolean|undefined),
|
|
* iconElement: (!HTMLElement|undefined),
|
|
* }}
|
|
*/
|
|
// @ts-ignore typedef
|
|
export let Suggestion;
|
|
|
|
/**
|
|
* @typedef {!Array<!Suggestion>}
|
|
*/
|
|
// @ts-ignore typedef
|
|
export let Suggestions;
|
|
|
|
/**
|
|
* @typedef {{
|
|
* substituteRangeCallback: ((function(number, number):?TextUtils.TextRange.TextRange)|undefined),
|
|
* tooltipCallback: ((function(number, number):!Promise<?Element>)|undefined),
|
|
* suggestionsCallback: ((function(!TextUtils.TextRange.TextRange, !TextUtils.TextRange.TextRange, boolean=):?Promise.<!Suggestions>)|undefined),
|
|
* isWordChar: ((function(string):boolean)|undefined),
|
|
* anchorBehavior: (AnchorBehavior|undefined)
|
|
* }}
|
|
*/
|
|
// @ts-ignore typedef
|
|
export let AutocompleteConfig;
|