| // 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 |
| */ |
| export class TextEditorAutocompleteController { |
| /** |
| * @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 {?UI.SuggestBox.Suggestion} */ |
| 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) { |
| if (this._updatedLines[i] === undefined) { |
| 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 {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 {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); |
| 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._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.trimEndWithMaxLength(10000); |
| 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, 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 {?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() { |
| const selections = this._codeMirror.listSelections().slice(); |
| const queryLength = this._queryRange.endColumn - this._queryRange.startColumn; |
| const suggestion = this._currentSuggestion.text; |
| 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.text; |
| 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; |
| } |
| } |
| |
| TextEditorAutocompleteController.HintBookmark = Symbol('hint'); |
| |
| /* Legacy exported object */ |
| self.TextEditor = self.TextEditor || {}; |
| |
| /* Legacy exported object */ |
| TextEditor = TextEditor || {}; |
| |
| /** @constructor */ |
| TextEditor.TextEditorAutocompleteController = TextEditorAutocompleteController; |