diff options
Diffstat (limited to 'devtools/client/shared/sourceeditor/autocomplete.js')
-rw-r--r-- | devtools/client/shared/sourceeditor/autocomplete.js | 358 |
1 files changed, 358 insertions, 0 deletions
diff --git a/devtools/client/shared/sourceeditor/autocomplete.js b/devtools/client/shared/sourceeditor/autocomplete.js new file mode 100644 index 0000000000..555bcf581c --- /dev/null +++ b/devtools/client/shared/sourceeditor/autocomplete.js @@ -0,0 +1,358 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); + +loader.lazyRequireGetter( + this, + "KeyCodes", + "resource://devtools/client/shared/keycodes.js", + true +); +loader.lazyRequireGetter( + this, + "CSSCompleter", + "resource://devtools/client/shared/sourceeditor/css-autocompleter.js" +); + +const autocompleteMap = new WeakMap(); + +/** + * Prepares an editor instance for autocompletion. + */ +function initializeAutoCompletion(ctx, options = {}) { + const { cm, ed, Editor } = ctx; + if (autocompleteMap.has(ed)) { + return; + } + + const win = ed.container.contentWindow.wrappedJSObject; + const { CodeMirror } = win; + + let completer = null; + const autocompleteKey = + "Ctrl-" + Editor.keyFor("autocompletion", { noaccel: true }); + if (ed.config.mode == Editor.modes.css) { + completer = new CSSCompleter({ + walker: options.walker, + cssProperties: options.cssProperties, + }); + } + + function insertSelectedPopupItem() { + const autocompleteState = autocompleteMap.get(ed); + if (!popup || !popup.isOpen || !autocompleteState) { + return false; + } + + if (!autocompleteState.suggestionInsertedOnce && popup.selectedItem) { + autocompleteMap.get(ed).insertingSuggestion = true; + insertPopupItem(ed, popup.selectedItem); + } + + popup.once("popup-closed", () => { + // This event is used in tests. + ed.emit("popup-hidden"); + }); + popup.hidePopup(); + return true; + } + + // Give each popup a new name to avoid sharing the elements. + + let popup = new AutocompletePopup(win.parent.document, { + position: "bottom", + autoSelect: true, + onClick: insertSelectedPopupItem, + }); + + const cycle = reverse => { + if (popup?.isOpen) { + // eslint-disable-next-line mozilla/no-compare-against-boolean-literals + cycleSuggestions(ed, reverse == true); + return null; + } + + return CodeMirror.Pass; + }; + + let keyMap = { + Tab: cycle, + Down: cycle, + "Shift-Tab": cycle.bind(null, true), + Up: cycle.bind(null, true), + Enter: () => { + const wasHandled = insertSelectedPopupItem(); + return wasHandled ? true : CodeMirror.Pass; + }, + }; + + const autoCompleteCallback = autoComplete.bind(null, ctx); + const keypressCallback = onEditorKeypress.bind(null, ctx); + keyMap[autocompleteKey] = autoCompleteCallback; + cm.addKeyMap(keyMap); + + cm.on("keydown", keypressCallback); + ed.on("change", autoCompleteCallback); + ed.on("destroy", destroy); + + function destroy() { + ed.off("destroy", destroy); + cm.off("keydown", keypressCallback); + ed.off("change", autoCompleteCallback); + cm.removeKeyMap(keyMap); + popup.destroy(); + keyMap = popup = completer = null; + autocompleteMap.delete(ed); + } + + autocompleteMap.set(ed, { + popup, + completer, + keyMap, + destroy, + insertingSuggestion: false, + suggestionInsertedOnce: false, + }); +} + +/** + * Destroy autocompletion on an editor instance. + */ +function destroyAutoCompletion(ctx) { + const { ed } = ctx; + if (!autocompleteMap.has(ed)) { + return; + } + + const { destroy } = autocompleteMap.get(ed); + destroy(); +} + +/** + * Provides suggestions to autocomplete the current token/word being typed. + */ +function autoComplete({ ed, cm }) { + const autocompleteOpts = autocompleteMap.get(ed); + const { completer, popup } = autocompleteOpts; + if ( + !completer || + autocompleteOpts.insertingSuggestion || + autocompleteOpts.doNotAutocomplete + ) { + autocompleteOpts.insertingSuggestion = false; + return; + } + const cur = ed.getCursor(); + completer + .complete(cm.getRange({ line: 0, ch: 0 }, cur), cur) + .then(suggestions => { + if ( + !suggestions || + !suggestions.length || + suggestions[0].preLabel == null + ) { + autocompleteOpts.suggestionInsertedOnce = false; + popup.once("popup-closed", () => { + // This event is used in tests. + ed.emit("after-suggest"); + }); + popup.hidePopup(); + return; + } + // The cursor is at the end of the currently entered part of the token, + // like "backgr|" but we need to open the popup at the beginning of the + // character "b". Thus we need to calculate the width of the entered part + // of the token ("backgr" here). + + const cursorElement = + cm.display.cursorDiv.querySelector(".CodeMirror-cursor"); + const left = suggestions[0].preLabel.length * cm.defaultCharWidth(); + popup.hidePopup(); + popup.setItems(suggestions); + + popup.once("popup-opened", () => { + // This event is used in tests. + ed.emit("after-suggest"); + }); + popup.openPopup(cursorElement, -1 * left, 0); + autocompleteOpts.suggestionInsertedOnce = false; + }) + .catch(console.error); +} + +/** + * Inserts a popup item into the current cursor location + * in the editor. + */ +function insertPopupItem(ed, popupItem) { + const { preLabel, text } = popupItem; + const cur = ed.getCursor(); + const textBeforeCursor = ed.getText(cur.line).substring(0, cur.ch); + const backwardsTextBeforeCursor = textBeforeCursor + .split("") + .reverse() + .join(""); + const backwardsPreLabel = preLabel.split("").reverse().join(""); + + // If there is additional text in the preLabel vs the line, then + // just insert the entire autocomplete text. An example: + // if you type 'a' and select '#about' from the autocomplete menu, + // then the final text needs to the end up as '#about'. + if (backwardsPreLabel.indexOf(backwardsTextBeforeCursor) === 0) { + ed.replaceText(text, { line: cur.line, ch: 0 }, cur); + } else { + ed.replaceText(text.slice(preLabel.length), cur, cur); + } +} + +/** + * Cycles through provided suggestions by the popup in a top to bottom manner + * when `reverse` is not true. Opposite otherwise. + */ +function cycleSuggestions(ed, reverse) { + const autocompleteOpts = autocompleteMap.get(ed); + const { popup } = autocompleteOpts; + const cur = ed.getCursor(); + autocompleteOpts.insertingSuggestion = true; + if (!autocompleteOpts.suggestionInsertedOnce) { + autocompleteOpts.suggestionInsertedOnce = true; + let firstItem; + if (reverse) { + firstItem = popup.getItemAtIndex(popup.itemCount - 1); + popup.selectPreviousItem(); + } else { + firstItem = popup.getItemAtIndex(0); + if (firstItem.label == firstItem.preLabel && popup.itemCount > 1) { + firstItem = popup.getItemAtIndex(1); + popup.selectNextItem(); + } + } + if (popup.itemCount == 1) { + popup.hidePopup(); + } + insertPopupItem(ed, firstItem); + } else { + const fromCur = { + line: cur.line, + ch: cur.ch - popup.selectedItem.text.length, + }; + if (reverse) { + popup.selectPreviousItem(); + } else { + popup.selectNextItem(); + } + ed.replaceText(popup.selectedItem.text, fromCur, cur); + } + // This event is used in tests. + ed.emit("suggestion-entered"); +} + +/** + * onkeydown handler for the editor instance to prevent autocompleting on some + * keypresses. + */ +function onEditorKeypress({ ed, Editor }, cm, event) { + const autocompleteOpts = autocompleteMap.get(ed); + + // Do not try to autocomplete with multiple selections. + if (ed.hasMultipleSelections()) { + autocompleteOpts.doNotAutocomplete = true; + autocompleteOpts.popup.hidePopup(); + return; + } + + if ( + (event.ctrlKey || event.metaKey) && + event.keyCode == KeyCodes.DOM_VK_SPACE + ) { + // When Ctrl/Cmd + Space is pressed, two simultaneous keypresses are emitted + // first one for just the Ctrl/Cmd and second one for combo. The first one + // leave the autocompleteOpts.doNotAutocomplete as true, so we have to make + // it false + autocompleteOpts.doNotAutocomplete = false; + return; + } + + if (event.ctrlKey || event.metaKey || event.altKey) { + autocompleteOpts.doNotAutocomplete = true; + autocompleteOpts.popup.hidePopup(); + return; + } + + switch (event.keyCode) { + case KeyCodes.DOM_VK_RETURN: + autocompleteOpts.doNotAutocomplete = true; + break; + case KeyCodes.DOM_VK_ESCAPE: + if (autocompleteOpts.popup.isOpen) { + // Prevent the Console input to open, but still remove the autocomplete popup. + autocompleteOpts.doNotAutocomplete = true; + autocompleteOpts.popup.hidePopup(); + event.preventDefault(); + } + break; + case KeyCodes.DOM_VK_LEFT: + case KeyCodes.DOM_VK_RIGHT: + case KeyCodes.DOM_VK_HOME: + case KeyCodes.DOM_VK_END: + autocompleteOpts.doNotAutocomplete = true; + autocompleteOpts.popup.hidePopup(); + break; + case KeyCodes.DOM_VK_BACK_SPACE: + case KeyCodes.DOM_VK_DELETE: + if (ed.config.mode == Editor.modes.css) { + autocompleteOpts.completer.invalidateCache(ed.getCursor().line); + } + autocompleteOpts.doNotAutocomplete = true; + autocompleteOpts.popup.hidePopup(); + break; + default: + autocompleteOpts.doNotAutocomplete = false; + } +} + +/** + * Returns the private popup. This method is used by tests to test the feature. + */ +function getPopup({ ed }) { + if (autocompleteMap.has(ed)) { + return autocompleteMap.get(ed).popup; + } + + return null; +} + +/** + * Returns contextual information about the token covered by the caret if the + * implementation of completer supports it. + */ +function getInfoAt({ ed }, caret) { + if (autocompleteMap.has(ed)) { + const completer = autocompleteMap.get(ed).completer; + if (completer?.getInfoAt) { + return completer.getInfoAt(ed.getText(), caret); + } + } + + return null; +} + +/** + * Returns whether autocompletion is enabled for this editor. + * Used for testing + */ +function isAutocompletionEnabled({ ed }) { + return autocompleteMap.has(ed); +} + +// Export functions + +module.exports.initializeAutoCompletion = initializeAutoCompletion; +module.exports.destroyAutoCompletion = destroyAutoCompletion; +module.exports.getAutocompletionPopup = getPopup; +module.exports.getInfoAt = getInfoAt; +module.exports.isAutocompletionEnabled = isAutocompletionEnabled; |