mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-01 01:38:07 +08:00
https://i.imgur.com/Y3OlPM2.png Change-Id: Ic1d9ef1d47ff93ce77bfc3ae7173689bccf0cb2c Reviewed-on: https://chromium-review.googlesource.com/1031096 Reviewed-by: Andrey Lushnikov <lushnikov@chromium.org> Commit-Queue: Joel Einbinder <einbinder@chromium.org> Cr-Original-Commit-Position: refs/heads/master@{#557030} Cr-Mirrored-From: https://chromium.googlesource.com/chromium/src Cr-Mirrored-Commit: 66bdf19b13c9a1dffbd47121101e0b8aedde3495
513 lines
17 KiB
JavaScript
513 lines
17 KiB
JavaScript
// Copyright (c) 2014 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.
|
|
/**
|
|
* @implements {UI.SuggestBoxDelegate}
|
|
* @unrestricted
|
|
*/
|
|
TextEditor.TextEditorAutocompleteController = class {
|
|
/**
|
|
* @param {!TextEditor.CodeMirrorTextEditor} textEditor
|
|
* @param {!CodeMirror} codeMirror
|
|
* @param {!UI.AutocompleteConfig} config
|
|
*/
|
|
constructor(textEditor, codeMirror, config) {
|
|
this._textEditor = textEditor;
|
|
this._codeMirror = codeMirror;
|
|
this._config = config;
|
|
this._initialized = false;
|
|
|
|
this._onScroll = this._onScroll.bind(this);
|
|
this._onCursorActivity = this._onCursorActivity.bind(this);
|
|
this._changes = this._changes.bind(this);
|
|
this._blur = this._blur.bind(this);
|
|
this._beforeChange = this._beforeChange.bind(this);
|
|
this._mouseDown = () => {
|
|
this.clearAutocomplete();
|
|
this._tooltipGlassPane.hide();
|
|
};
|
|
this._codeMirror.on('changes', this._changes);
|
|
this._lastHintText = '';
|
|
/** @type {?UI.SuggestBox} */
|
|
this._suggestBox = null;
|
|
/** @type {?string} */
|
|
this._currentSuggestion = null;
|
|
this._hintElement = createElementWithClass('span', 'auto-complete-text');
|
|
|
|
this._tooltipGlassPane = new UI.GlassPane();
|
|
this._tooltipGlassPane.setSizeBehavior(UI.GlassPane.SizeBehavior.MeasureContent);
|
|
this._tooltipGlassPane.setOutsideClickCallback(this._tooltipGlassPane.hide.bind(this._tooltipGlassPane));
|
|
this._tooltipElement = createElementWithClass('div', 'autocomplete-tooltip');
|
|
const shadowRoot =
|
|
UI.createShadowRootWithCoreStyles(this._tooltipGlassPane.contentElement, 'text_editor/autocompleteTooltip.css');
|
|
shadowRoot.appendChild(this._tooltipElement);
|
|
}
|
|
|
|
_initializeIfNeeded() {
|
|
if (this._initialized)
|
|
return;
|
|
this._initialized = true;
|
|
this._codeMirror.on('scroll', this._onScroll);
|
|
this._codeMirror.on('cursorActivity', this._onCursorActivity);
|
|
this._codeMirror.on('mousedown', this._mouseDown);
|
|
this._codeMirror.on('blur', this._blur);
|
|
if (this._config.isWordChar) {
|
|
this._codeMirror.on('beforeChange', this._beforeChange);
|
|
this._dictionary = new Common.TextDictionary();
|
|
this._addWordsFromText(this._codeMirror.getValue());
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
this._codeMirror.off('changes', this._changes);
|
|
if (this._initialized) {
|
|
this._codeMirror.off('scroll', this._onScroll);
|
|
this._codeMirror.off('cursorActivity', this._onCursorActivity);
|
|
this._codeMirror.off('mousedown', this._mouseDown);
|
|
this._codeMirror.off('blur', this._blur);
|
|
}
|
|
if (this._dictionary) {
|
|
this._codeMirror.off('beforeChange', this._beforeChange);
|
|
this._dictionary.reset();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!CodeMirror} codeMirror
|
|
* @param {!CodeMirror.BeforeChangeObject} changeObject
|
|
*/
|
|
_beforeChange(codeMirror, changeObject) {
|
|
this._updatedLines = this._updatedLines || {};
|
|
for (let i = changeObject.from.line; i <= changeObject.to.line; ++i)
|
|
this._updatedLines[i] = this._codeMirror.getLine(i);
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
*/
|
|
_addWordsFromText(text) {
|
|
TextUtils.TextUtils.textToWords(
|
|
text, /** @type {function(string):boolean} */ (this._config.isWordChar), addWord.bind(this));
|
|
|
|
/**
|
|
* @param {string} word
|
|
* @this {TextEditor.TextEditorAutocompleteController}
|
|
*/
|
|
function addWord(word) {
|
|
if (word.length && (word[0] < '0' || word[0] > '9'))
|
|
this._dictionary.addWord(word);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
*/
|
|
_removeWordsFromText(text) {
|
|
TextUtils.TextUtils.textToWords(
|
|
text, /** @type {function(string):boolean} */ (this._config.isWordChar),
|
|
word => this._dictionary.removeWord(word));
|
|
}
|
|
|
|
/**
|
|
* @param {number} lineNumber
|
|
* @param {number} columnNumber
|
|
* @return {?TextUtils.TextRange}
|
|
*/
|
|
_substituteRange(lineNumber, columnNumber) {
|
|
let range =
|
|
this._config.substituteRangeCallback ? this._config.substituteRangeCallback(lineNumber, columnNumber) : null;
|
|
if (!range && this._config.isWordChar)
|
|
range = this._textEditor.wordRangeForCursorPosition(lineNumber, columnNumber, this._config.isWordChar);
|
|
return range;
|
|
}
|
|
|
|
/**
|
|
* @param {!TextUtils.TextRange} queryRange
|
|
* @param {!TextUtils.TextRange} substituteRange
|
|
* @param {boolean=} force
|
|
* @return {!Promise.<!UI.SuggestBox.Suggestions>}
|
|
*/
|
|
_wordsWithQuery(queryRange, substituteRange, force) {
|
|
const external =
|
|
this._config.suggestionsCallback ? this._config.suggestionsCallback(queryRange, substituteRange, force) : null;
|
|
if (external)
|
|
return external;
|
|
|
|
if (!this._dictionary || (!force && queryRange.isEmpty()))
|
|
return Promise.resolve([]);
|
|
|
|
let completions = this._dictionary.wordsWithPrefix(this._textEditor.text(queryRange));
|
|
const substituteWord = this._textEditor.text(substituteRange);
|
|
if (this._dictionary.wordCount(substituteWord) === 1)
|
|
completions = completions.filter(word => word !== substituteWord);
|
|
|
|
completions.sort((a, b) => this._dictionary.wordCount(b) - this._dictionary.wordCount(a) || a.length - b.length);
|
|
return Promise.resolve(completions.map(item => ({text: item})));
|
|
}
|
|
|
|
/**
|
|
* @param {!CodeMirror} codeMirror
|
|
* @param {!Array.<!CodeMirror.ChangeObject>} changes
|
|
*/
|
|
_changes(codeMirror, changes) {
|
|
if (!changes.length)
|
|
return;
|
|
|
|
if (this._dictionary && this._updatedLines) {
|
|
for (const lineNumber in this._updatedLines)
|
|
this._removeWordsFromText(this._updatedLines[lineNumber]);
|
|
delete this._updatedLines;
|
|
|
|
const linesToUpdate = {};
|
|
for (let changeIndex = 0; changeIndex < changes.length; ++changeIndex) {
|
|
const changeObject = changes[changeIndex];
|
|
const editInfo = TextEditor.CodeMirrorUtils.changeObjectToEditOperation(changeObject);
|
|
for (let i = editInfo.newRange.startLine; i <= editInfo.newRange.endLine; ++i)
|
|
linesToUpdate[i] = this._codeMirror.getLine(i);
|
|
}
|
|
for (const lineNumber in linesToUpdate)
|
|
this._addWordsFromText(linesToUpdate[lineNumber]);
|
|
}
|
|
|
|
let singleCharInput = false;
|
|
let singleCharDelete = false;
|
|
const cursor = this._codeMirror.getCursor('head');
|
|
for (let changeIndex = 0; changeIndex < changes.length; ++changeIndex) {
|
|
const changeObject = changes[changeIndex];
|
|
if (changeObject.origin === '+input' && changeObject.text.length === 1 && changeObject.text[0].length === 1 &&
|
|
changeObject.to.line === cursor.line && changeObject.to.ch + 1 === cursor.ch) {
|
|
singleCharInput = true;
|
|
break;
|
|
}
|
|
if (changeObject.origin === '+delete' && changeObject.removed.length === 1 &&
|
|
changeObject.removed[0].length === 1 && changeObject.to.line === cursor.line &&
|
|
changeObject.to.ch - 1 === cursor.ch) {
|
|
singleCharDelete = true;
|
|
break;
|
|
}
|
|
}
|
|
if (this._queryRange) {
|
|
if (singleCharInput)
|
|
this._queryRange.endColumn++;
|
|
else if (singleCharDelete)
|
|
this._queryRange.endColumn--;
|
|
if (singleCharDelete || singleCharInput)
|
|
this._setHint(this._lastHintText);
|
|
}
|
|
|
|
if (singleCharInput || singleCharDelete)
|
|
setImmediate(this.autocomplete.bind(this));
|
|
else
|
|
this.clearAutocomplete();
|
|
}
|
|
|
|
_blur() {
|
|
this.clearAutocomplete();
|
|
}
|
|
|
|
/**
|
|
* @param {!TextUtils.TextRange} mainSelection
|
|
* @return {boolean}
|
|
*/
|
|
_validateSelectionsContexts(mainSelection) {
|
|
const selections = this._codeMirror.listSelections();
|
|
if (selections.length <= 1)
|
|
return true;
|
|
const mainSelectionContext = this._textEditor.text(mainSelection);
|
|
for (let i = 0; i < selections.length; ++i) {
|
|
const wordRange = this._substituteRange(selections[i].head.line, selections[i].head.ch);
|
|
if (!wordRange)
|
|
return false;
|
|
const context = this._textEditor.text(wordRange);
|
|
if (context !== mainSelectionContext)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param {boolean=} force
|
|
*/
|
|
autocomplete(force) {
|
|
this._initializeIfNeeded();
|
|
if (this._codeMirror.somethingSelected()) {
|
|
this._hideSuggestBox();
|
|
return;
|
|
}
|
|
|
|
const cursor = this._codeMirror.getCursor('head');
|
|
const substituteRange = this._substituteRange(cursor.line, cursor.ch);
|
|
if (!substituteRange || !this._validateSelectionsContexts(substituteRange)) {
|
|
this._hideSuggestBox();
|
|
return;
|
|
}
|
|
|
|
const queryRange = substituteRange.clone();
|
|
queryRange.endColumn = cursor.ch;
|
|
const query = this._textEditor.text(queryRange);
|
|
let hadSuggestBox = false;
|
|
if (this._suggestBox)
|
|
hadSuggestBox = true;
|
|
this._wordsWithQuery(queryRange, substituteRange, force).then(wordsAcquired.bind(this));
|
|
|
|
/**
|
|
* @param {!UI.SuggestBox.Suggestions} wordsWithQuery
|
|
* @this {TextEditor.TextEditorAutocompleteController}
|
|
*/
|
|
function wordsAcquired(wordsWithQuery) {
|
|
if (!wordsWithQuery.length || (wordsWithQuery.length === 1 && query === wordsWithQuery[0].text) ||
|
|
(!this._suggestBox && hadSuggestBox)) {
|
|
this._hideSuggestBox();
|
|
this._onSuggestionsShownForTest([]);
|
|
return;
|
|
}
|
|
if (!this._suggestBox)
|
|
this._suggestBox = new UI.SuggestBox(this, 20);
|
|
|
|
const oldQueryRange = this._queryRange;
|
|
this._queryRange = queryRange;
|
|
if (!oldQueryRange || queryRange.startLine !== oldQueryRange.startLine ||
|
|
queryRange.startColumn !== oldQueryRange.startColumn)
|
|
this._updateAnchorBox();
|
|
this._suggestBox.updateSuggestions(this._anchorBox, wordsWithQuery, true, !this._isCursorAtEndOfLine(), query);
|
|
if (this._suggestBox.visible)
|
|
this._tooltipGlassPane.hide();
|
|
this._onSuggestionsShownForTest(wordsWithQuery);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} hint
|
|
*/
|
|
_setHint(hint) {
|
|
const query = this._textEditor.text(this._queryRange);
|
|
if (!hint || !this._isCursorAtEndOfLine() || !hint.startsWith(query)) {
|
|
this._clearHint();
|
|
return;
|
|
}
|
|
const suffix = hint.substring(query.length).split('\n')[0];
|
|
this._hintElement.textContent = suffix;
|
|
const cursor = this._codeMirror.getCursor('to');
|
|
if (this._hintMarker) {
|
|
const position = this._hintMarker.position();
|
|
if (!position || !position.equal(TextUtils.TextRange.createFromLocation(cursor.line, cursor.ch))) {
|
|
this._hintMarker.clear();
|
|
this._hintMarker = null;
|
|
}
|
|
}
|
|
|
|
if (!this._hintMarker) {
|
|
this._hintMarker = this._textEditor.addBookmark(
|
|
cursor.line, cursor.ch, this._hintElement, TextEditor.TextEditorAutocompleteController.HintBookmark, true);
|
|
} else if (this._lastHintText !== hint) {
|
|
this._hintMarker.refresh();
|
|
}
|
|
this._lastHintText = hint;
|
|
}
|
|
|
|
_clearHint() {
|
|
if (!this._hintElement.textContent)
|
|
return;
|
|
this._lastHintText = '';
|
|
this._hintElement.textContent = '';
|
|
if (this._hintMarker)
|
|
this._hintMarker.refresh();
|
|
}
|
|
|
|
/**
|
|
* @param {!UI.SuggestBox.Suggestions} suggestions
|
|
*/
|
|
_onSuggestionsShownForTest(suggestions) {
|
|
}
|
|
|
|
_onSuggestionsHiddenForTest() {
|
|
}
|
|
|
|
clearAutocomplete() {
|
|
this._tooltipGlassPane.hide();
|
|
this._hideSuggestBox();
|
|
}
|
|
|
|
_hideSuggestBox() {
|
|
if (!this._suggestBox)
|
|
return;
|
|
this._suggestBox.hide();
|
|
this._suggestBox = null;
|
|
this._queryRange = null;
|
|
this._anchorBox = null;
|
|
this._currentSuggestion = null;
|
|
this._textEditor.dispatchEventToListeners(UI.TextEditor.Events.SuggestionChanged);
|
|
this._clearHint();
|
|
this._onSuggestionsHiddenForTest();
|
|
}
|
|
|
|
/**
|
|
* @param {!KeyboardEvent} event
|
|
* @return {boolean}
|
|
*/
|
|
keyDown(event) {
|
|
if (this._tooltipGlassPane.isShowing() && event.keyCode === UI.KeyboardShortcut.Keys.Esc.code) {
|
|
this._tooltipGlassPane.hide();
|
|
return true;
|
|
}
|
|
if (!this._suggestBox)
|
|
return false;
|
|
switch (event.keyCode) {
|
|
case UI.KeyboardShortcut.Keys.Tab.code:
|
|
this._suggestBox.acceptSuggestion();
|
|
this.clearAutocomplete();
|
|
return true;
|
|
case UI.KeyboardShortcut.Keys.End.code:
|
|
case UI.KeyboardShortcut.Keys.Right.code:
|
|
if (this._isCursorAtEndOfLine()) {
|
|
this._suggestBox.acceptSuggestion();
|
|
this.clearAutocomplete();
|
|
return true;
|
|
} else {
|
|
this.clearAutocomplete();
|
|
return false;
|
|
}
|
|
case UI.KeyboardShortcut.Keys.Left.code:
|
|
case UI.KeyboardShortcut.Keys.Home.code:
|
|
this.clearAutocomplete();
|
|
return false;
|
|
case UI.KeyboardShortcut.Keys.Esc.code:
|
|
this.clearAutocomplete();
|
|
return true;
|
|
}
|
|
return this._suggestBox.keyPressed(event);
|
|
}
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
_isCursorAtEndOfLine() {
|
|
const cursor = this._codeMirror.getCursor('to');
|
|
return cursor.ch === this._codeMirror.getLine(cursor.line).length;
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @param {string} suggestion
|
|
* @param {boolean=} isIntermediateSuggestion
|
|
*/
|
|
applySuggestion(suggestion, isIntermediateSuggestion) {
|
|
const oldSuggestion = this._currentSuggestion;
|
|
this._currentSuggestion = suggestion;
|
|
this._setHint(suggestion);
|
|
if (oldSuggestion !== suggestion)
|
|
this._textEditor.dispatchEventToListeners(UI.TextEditor.Events.SuggestionChanged);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
acceptSuggestion() {
|
|
const selections = this._codeMirror.listSelections().slice();
|
|
const queryLength = this._queryRange.endColumn - this._queryRange.startColumn;
|
|
const suggestion = this._currentSuggestion;
|
|
this._codeMirror.operation(() => {
|
|
for (let i = selections.length - 1; i >= 0; --i) {
|
|
const start = selections[i].head;
|
|
const end = new CodeMirror.Pos(start.line, start.ch - queryLength);
|
|
this._codeMirror.replaceRange(suggestion, start, end, '+autocomplete');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return {string}
|
|
*/
|
|
textWithCurrentSuggestion() {
|
|
if (!this._queryRange || this._currentSuggestion === null)
|
|
return this._codeMirror.getValue();
|
|
|
|
const selections = this._codeMirror.listSelections().slice();
|
|
let last = {line: 0, column: 0};
|
|
let text = '';
|
|
const queryLength = this._queryRange.endColumn - this._queryRange.startColumn;
|
|
for (const selection of selections) {
|
|
const range =
|
|
new TextUtils.TextRange(last.line, last.column, selection.head.line, selection.head.ch - queryLength);
|
|
text += this._textEditor.text(range);
|
|
text += this._currentSuggestion;
|
|
last = {line: selection.head.line, column: selection.head.ch};
|
|
}
|
|
const range = new TextUtils.TextRange(last.line, last.column, Infinity, Infinity);
|
|
text += this._textEditor.text(range);
|
|
return text;
|
|
}
|
|
|
|
_onScroll() {
|
|
this._tooltipGlassPane.hide();
|
|
if (!this._suggestBox)
|
|
return;
|
|
const cursor = this._codeMirror.getCursor();
|
|
const scrollInfo = this._codeMirror.getScrollInfo();
|
|
const topmostLineNumber = this._codeMirror.lineAtHeight(scrollInfo.top, 'local');
|
|
const bottomLine = this._codeMirror.lineAtHeight(scrollInfo.top + scrollInfo.clientHeight, 'local');
|
|
if (cursor.line < topmostLineNumber || cursor.line > bottomLine) {
|
|
this.clearAutocomplete();
|
|
} else {
|
|
this._updateAnchorBox();
|
|
this._suggestBox.setPosition(this._anchorBox);
|
|
}
|
|
}
|
|
|
|
async _updateTooltip() {
|
|
const cursor = this._codeMirror.getCursor();
|
|
const tooltip = this._config.tooltipCallback ? await this._config.tooltipCallback(cursor.line, cursor.ch) : null;
|
|
const newCursor = this._codeMirror.getCursor();
|
|
|
|
if (newCursor.line !== cursor.line && newCursor.ch !== cursor.ch)
|
|
return;
|
|
if (this._suggestBox && this._suggestBox.visible)
|
|
return;
|
|
|
|
if (!tooltip) {
|
|
this._tooltipGlassPane.hide();
|
|
return;
|
|
}
|
|
const metrics = this._textEditor.cursorPositionToCoordinates(cursor.line, cursor.ch);
|
|
if (!metrics) {
|
|
this._tooltipGlassPane.hide();
|
|
return;
|
|
}
|
|
|
|
this._tooltipGlassPane.setContentAnchorBox(new AnchorBox(metrics.x, metrics.y, 0, metrics.height));
|
|
this._tooltipElement.removeChildren();
|
|
this._tooltipElement.appendChild(tooltip);
|
|
this._tooltipGlassPane.show(/** @type {!Document} */ (this._textEditor.element.ownerDocument));
|
|
}
|
|
|
|
_onCursorActivity() {
|
|
this._updateTooltip();
|
|
if (!this._suggestBox)
|
|
return;
|
|
const cursor = this._codeMirror.getCursor();
|
|
let shouldCloseAutocomplete =
|
|
!(cursor.line === this._queryRange.startLine && this._queryRange.startColumn <= cursor.ch &&
|
|
cursor.ch <= this._queryRange.endColumn);
|
|
// Try not to hide autocomplete when user types in.
|
|
if (cursor.line === this._queryRange.startLine && cursor.ch === this._queryRange.endColumn + 1) {
|
|
const line = this._codeMirror.getLine(cursor.line);
|
|
shouldCloseAutocomplete = this._config.isWordChar ? !this._config.isWordChar(line.charAt(cursor.ch - 1)) : false;
|
|
}
|
|
if (shouldCloseAutocomplete)
|
|
this.clearAutocomplete();
|
|
this._onCursorActivityHandledForTest();
|
|
}
|
|
|
|
_onCursorActivityHandledForTest() {
|
|
}
|
|
|
|
_updateAnchorBox() {
|
|
const line = this._queryRange.startLine;
|
|
const column = this._queryRange.startColumn;
|
|
const metrics = this._textEditor.cursorPositionToCoordinates(line, column);
|
|
this._anchorBox = metrics ? new AnchorBox(metrics.x, metrics.y, 0, metrics.height) : null;
|
|
}
|
|
};
|
|
|
|
TextEditor.TextEditorAutocompleteController.HintBookmark = Symbol('hint');
|