UNPKG

chrome-devtools-frontend

Version:
626 lines (575 loc) • 21.8 kB
// 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. import * as Common from '../common/common.js'; import * as Platform from '../platform/platform.js'; import * as TextUtils from '../text_utils/text_utils.js'; import * as UI from '../ui/ui.js'; import {CodeMirrorTextEditor} from './CodeMirrorTextEditor.js'; // eslint-disable-line no-unused-vars import {changeObjectToEditOperation} from './CodeMirrorUtils.js'; /** * @implements {UI.SuggestBox.SuggestBoxDelegate} */ export class TextEditorAutocompleteController { // https://crbug.com/1151919 * = CodeMirror.Editor /** * @param {!CodeMirrorTextEditor} textEditor * @param {*} codeMirror * @param {!UI.TextEditor.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(); }; // @ts-ignore CodeMirror types are wrong. this._codeMirror.on('changes', this._changes); this._lastHintText = ''; /** @type {?UI.SuggestBox.SuggestBox} */ this._suggestBox = null; /** @type {?UI.SuggestBox.Suggestion} */ this._currentSuggestion = null; this._hintElement = document.createElement('span'); this._hintElement.classList.add('auto-complete-text'); this._tooltipGlassPane = new UI.GlassPane.GlassPane(); this._tooltipGlassPane.setSizeBehavior(UI.GlassPane.SizeBehavior.MeasureContent); this._tooltipGlassPane.setOutsideClickCallback(this._tooltipGlassPane.hide.bind(this._tooltipGlassPane)); this._tooltipElement = document.createElement('div'); this._tooltipElement.classList.add('autocomplete-tooltip'); const shadowRoot = UI.Utils.createShadowRootWithCoreStyles( this._tooltipGlassPane.contentElement, {cssFile: 'text_editor/autocompleteTooltip.css', enableLegacyPatching: true, delegatesFocus: undefined}); shadowRoot.appendChild(this._tooltipElement); /** @type {?TextUtils.TextRange.TextRange} */ this._queryRange = null; } _initializeIfNeeded() { if (this._initialized) { return; } this._initialized = true; // @ts-ignore CodeMirror types are wrong. this._codeMirror.on('scroll', this._onScroll); // @ts-ignore CodeMirror types are wrong. this._codeMirror.on('cursorActivity', this._onCursorActivity); // @ts-ignore CodeMirror types are wrong. this._codeMirror.on('mousedown', this._mouseDown); // @ts-ignore CodeMirror types are wrong. this._codeMirror.on('blur', this._blur); if (this._config.isWordChar) { // @ts-ignore CodeMirror types are wrong. this._codeMirror.on('beforeChange', this._beforeChange); this._dictionary = new Common.TextDictionary.TextDictionary(); // @ts-ignore CodeMirror types are wrong. this._addWordsFromText(this._codeMirror.getValue()); } } dispose() { // @ts-ignore CodeMirror types are wrong. this._codeMirror.off('changes', this._changes); if (this._initialized) { // @ts-ignore CodeMirror types are wrong. this._codeMirror.off('scroll', this._onScroll); // @ts-ignore CodeMirror types are wrong. this._codeMirror.off('cursorActivity', this._onCursorActivity); // @ts-ignore CodeMirror types are wrong. this._codeMirror.off('mousedown', this._mouseDown); // @ts-ignore CodeMirror types are wrong. this._codeMirror.off('blur', this._blur); } if (this._dictionary) { // @ts-ignore CodeMirror types are wrong. this._codeMirror.off('beforeChange', this._beforeChange); this._dictionary.reset(); } } /** * @param {!CodeMirror} codeMirror * @param {*} changeObject (!CodeMirror.BeforeChangeObject) */ _beforeChange(codeMirror, changeObject) { this._updatedLines = this._updatedLines || {}; for (let i = changeObject.from.line; i <= changeObject.to.line; ++i) { if (this._updatedLines[i] === undefined) { // @ts-ignore CodeMirror types are wrong. this._updatedLines[i] = this._codeMirror.getLine(i); } } } /** * @param {string} text */ _addWordsFromText(text) { TextUtils.TextUtils.Utils.textToWords( text, /** @type {function(string):boolean} */ (this._config.isWordChar), addWord.bind(this)); /** * @param {string} word * @this {TextEditorAutocompleteController} */ function addWord(word) { if (this._dictionary && word.length && (word[0] < '0' || word[0] > '9')) { this._dictionary.addWord(word); } } } /** * @param {string} text */ _removeWordsFromText(text) { TextUtils.TextUtils.Utils.textToWords( text, /** @type {function(string):boolean} */ (this._config.isWordChar), word => this._dictionary && this._dictionary.removeWord(word)); } /** * @param {number} lineNumber * @param {number} columnNumber * @return {?TextUtils.TextRange.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.TextRange} queryRange * @param {!TextUtils.TextRange.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); } const dictionary = this._dictionary; completions.sort((a, b) => dictionary.wordCount(b) - dictionary.wordCount(a) || a.length - b.length); return Promise.resolve(completions.map(item => ({ text: item, title: undefined, subtitle: undefined, iconType: undefined, priority: undefined, isSecondary: undefined, subtitleRenderer: undefined, selectionRange: undefined, hideGhostText: undefined, iconElement: undefined }))); } /** * @param {!CodeMirror} codeMirror * @param {!Array.<*>} changes (CodeMirror.ChangeObject) */ _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; /** @type {!Object.<string, string>} */ const linesToUpdate = {}; for (let changeIndex = 0; changeIndex < changes.length; ++changeIndex) { const changeObject = changes[changeIndex]; const editInfo = changeObjectToEditOperation(changeObject); for (let i = editInfo.newRange.startLine; i <= editInfo.newRange.endLine; ++i) { // @ts-ignore CodeMirror types are wrong. linesToUpdate[String(i)] = this._codeMirror.getLine(i); } } for (const lineNumber in linesToUpdate) { this._addWordsFromText(linesToUpdate[lineNumber]); } } let singleCharInput = false; let singleCharDelete = false; // @ts-ignore CodeMirror types are wrong. 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) { queueMicrotask(() => { this.autocomplete(); }); } else { this.clearAutocomplete(); } } _blur() { this.clearAutocomplete(); } /** * @param {!TextUtils.TextRange.TextRange} mainSelection * @return {boolean} */ _validateSelectionsContexts(mainSelection) { // @ts-ignore CodeMirror types are wrong. 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(); // @ts-ignore CodeMirror types are wrong. if (this._codeMirror.somethingSelected()) { this._hideSuggestBox(); return; } // @ts-ignore CodeMirror types are wrong. 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(wordsWithQuery => { return wordsAcquired(wordsWithQuery); }); /** * @param {!UI.SuggestBox.Suggestions} wordsWithQuery */ const 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.SuggestBox(this, 20); if (this._config.anchorBehavior) { this._suggestBox.setAnchorBehavior(this._config.anchorBehavior); } } const oldQueryRange = this._queryRange; this._queryRange = queryRange; if (!oldQueryRange || queryRange.startLine !== oldQueryRange.startLine || queryRange.startColumn !== oldQueryRange.startColumn) { this._updateAnchorBox(); } this._anchorBox && 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) { if (this._queryRange === null) { return; } 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 = Platform.StringUtilities.trimEndWithMaxLength(suffix, 10000); // @ts-ignore CodeMirror types are wrong. const cursor = this._codeMirror.getCursor('to'); if (this._hintMarker) { const position = this._hintMarker.position(); if (!position || !position.equal(TextUtils.TextRange.TextRange.createFromLocation(cursor.line, cursor.ch))) { this._hintMarker.clear(); this._hintMarker = null; } } if (!this._hintMarker) { this._hintMarker = this._textEditor.addBookmark( cursor.line, cursor.ch, /** @type {!HTMLElement} */ (this._hintElement), 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; } 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() { // @ts-ignore CodeMirror types are wrong. const cursor = this._codeMirror.getCursor('to'); // @ts-ignore CodeMirror types are wrong. return cursor.ch === this._codeMirror.getLine(cursor.line).length; } /** * @override * @param {?UI.SuggestBox.Suggestion} suggestion * @param {boolean=} isIntermediateSuggestion */ applySuggestion(suggestion, isIntermediateSuggestion) { const oldSuggestion = this._currentSuggestion; this._currentSuggestion = suggestion; this._setHint(suggestion ? suggestion.text : ''); if ((oldSuggestion ? oldSuggestion.text : '') !== (suggestion ? suggestion.text : '')) { this._textEditor.dispatchEventToListeners(UI.TextEditor.Events.SuggestionChanged); } } /** * @override */ acceptSuggestion() { if (this._currentSuggestion === null || this._queryRange === null) { return; } // @ts-ignore CodeMirror types are wrong. const selections = this._codeMirror.listSelections().slice(); const queryLength = this._queryRange.endColumn - this._queryRange.startColumn; const suggestion = this._currentSuggestion.text; // @ts-ignore CodeMirror types are wrong. 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); // @ts-ignore CodeMirror types are wrong. this._codeMirror.replaceRange(suggestion, start, end, '+autocomplete'); } }); } /** * @return {string} */ textWithCurrentSuggestion() { if (!this._queryRange || this._currentSuggestion === null) { // @ts-ignore CodeMirror types are wrong. return this._codeMirror.getValue(); } // @ts-ignore CodeMirror types are wrong. 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.TextRange( last.line, last.column, selection.head.line, selection.head.ch - queryLength); text += this._textEditor.text(range); text += this._currentSuggestion.text; last = {line: selection.head.line, column: selection.head.ch}; } const range = new TextUtils.TextRange.TextRange(last.line, last.column, Infinity, Infinity); text += this._textEditor.text(range); return text; } _onScroll() { this._tooltipGlassPane.hide(); if (!this._suggestBox) { return; } // @ts-ignore CodeMirror types are wrong. const cursor = this._codeMirror.getCursor(); // @ts-ignore CodeMirror types are wrong. const scrollInfo = this._codeMirror.getScrollInfo(); // @ts-ignore CodeMirror types are wrong. const topmostLineNumber = this._codeMirror.lineAtHeight(scrollInfo.top, 'local'); // @ts-ignore CodeMirror types are wrong. const bottomLine = this._codeMirror.lineAtHeight(scrollInfo.top + scrollInfo.clientHeight, 'local'); if (cursor.line < topmostLineNumber || cursor.line > bottomLine) { this.clearAutocomplete(); } else { this._updateAnchorBox(); this._anchorBox && this._suggestBox.setPosition(this._anchorBox); } } async _updateTooltip() { // @ts-ignore CodeMirror types are wrong. const cursor = this._codeMirror.getCursor(); const tooltip = this._config.tooltipCallback ? await this._config.tooltipCallback(cursor.line, cursor.ch) : null; // @ts-ignore CodeMirror types are wrong. 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 || this._queryRange === null) { return; } // @ts-ignore CodeMirror types are wrong. 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) { // @ts-ignore CodeMirror types are wrong. 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() { if (this._queryRange === null) { return; } 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; } } TextEditorAutocompleteController.HintBookmark = Symbol('hint');