summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/sourceeditor/autocomplete.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/sourceeditor/autocomplete.js')
-rw-r--r--devtools/client/shared/sourceeditor/autocomplete.js358
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;