diff options
Diffstat (limited to 'devtools/client/sourceeditor/autocomplete.js')
-rw-r--r-- | devtools/client/sourceeditor/autocomplete.js | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/devtools/client/sourceeditor/autocomplete.js b/devtools/client/sourceeditor/autocomplete.js new file mode 100644 index 000000000..357f25ed1 --- /dev/null +++ b/devtools/client/sourceeditor/autocomplete.js @@ -0,0 +1,405 @@ +/* vim:set ts=2 sw=2 sts=2 et tw=80: + * 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 CSSCompleter = require("devtools/client/sourceeditor/css-autocompleter"); +const { AutocompletePopup } = require("devtools/client/shared/autocomplete-popup"); +const {KeyCodes} = require("devtools/client/shared/keycodes"); + +const CM_TERN_SCRIPTS = [ + "chrome://devtools/content/sourceeditor/codemirror/addon/tern/tern.js", + "chrome://devtools/content/sourceeditor/codemirror/addon/hint/show-hint.js" +]; + +const autocompleteMap = new WeakMap(); + +/** + * Prepares an editor instance for autocompletion. + */ +function initializeAutoCompletion(ctx, options = {}) { + let { cm, ed, Editor } = ctx; + if (autocompleteMap.has(ed)) { + return; + } + + let win = ed.container.contentWindow.wrappedJSObject; + let { CodeMirror, document } = win; + + let completer = null; + let autocompleteKey = "Ctrl-" + + Editor.keyFor("autocompletion", { noaccel: true }); + if (ed.config.mode == Editor.modes.js) { + let defs = [ + require("./tern/browser"), + require("./tern/ecma5"), + ]; + + CM_TERN_SCRIPTS.forEach(ed.loadScript, ed); + win.tern = require("./tern/tern"); + cm.tern = new CodeMirror.TernServer({ + defs: defs, + typeTip: function (data) { + let tip = document.createElement("span"); + tip.className = "CodeMirror-Tern-information"; + let tipType = document.createElement("strong"); + let tipText = document.createTextNode(data.type || + cm.l10n("autocompletion.notFound")); + tipType.appendChild(tipText); + tip.appendChild(tipType); + + if (data.doc) { + tip.appendChild(document.createTextNode(" — " + data.doc)); + } + + if (data.url) { + tip.appendChild(document.createTextNode(" ")); + let docLink = document.createElement("a"); + docLink.textContent = "[" + cm.l10n("autocompletion.docsLink") + "]"; + docLink.href = data.url; + docLink.className = "theme-link"; + docLink.setAttribute("target", "_blank"); + tip.appendChild(docLink); + } + + return tip; + } + }); + + let keyMap = {}; + let updateArgHintsCallback = cm.tern.updateArgHints.bind(cm.tern, cm); + cm.on("cursorActivity", updateArgHintsCallback); + + keyMap[autocompleteKey] = cmArg => { + cmArg.tern.getHint(cmArg, data => { + CodeMirror.on(data, "shown", () => ed.emit("before-suggest")); + CodeMirror.on(data, "close", () => ed.emit("after-suggest")); + CodeMirror.on(data, "select", () => ed.emit("suggestion-entered")); + CodeMirror.showHint(cmArg, (cmIgnore, cb) => cb(data), { async: true }); + }); + }; + + keyMap[Editor.keyFor("showInformation2", { noaccel: true })] = cmArg => { + cmArg.tern.showType(cmArg, null, () => { + ed.emit("show-information"); + }); + }; + cm.addKeyMap(keyMap); + + let destroyTern = function () { + ed.off("destroy", destroyTern); + cm.off("cursorActivity", updateArgHintsCallback); + cm.removeKeyMap(keyMap); + win.tern = cm.tern = null; + autocompleteMap.delete(ed); + }; + + ed.on("destroy", destroyTern); + + autocompleteMap.set(ed, { + destroy: destroyTern + }); + + // TODO: Integrate tern autocompletion with this autocomplete API. + return; + } else if (ed.config.mode == Editor.modes.css) { + completer = new CSSCompleter({walker: options.walker, + cssProperties: options.cssProperties}); + } + + function insertSelectedPopupItem() { + let 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", + theme: "auto", + autoSelect: true, + onClick: insertSelectedPopupItem + }); + + let cycle = reverse => { + if (popup && popup.isOpen) { + 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": () => { + let wasHandled = insertSelectedPopupItem(); + return wasHandled ? true : CodeMirror.Pass; + } + }; + + let autoCompleteCallback = autoComplete.bind(null, ctx); + let 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: popup, + completer: completer, + keyMap: keyMap, + destroy: destroy, + insertingSuggestion: false, + suggestionInsertedOnce: false + }); +} + +/** + * Destroy autocompletion on an editor instance. + */ +function destroyAutoCompletion(ctx) { + let { ed } = ctx; + if (!autocompleteMap.has(ed)) { + return; + } + + let {destroy} = autocompleteMap.get(ed); + destroy(); +} + +/** + * Provides suggestions to autocomplete the current token/word being typed. + */ +function autoComplete({ ed, cm }) { + let autocompleteOpts = autocompleteMap.get(ed); + let { completer, popup } = autocompleteOpts; + if (!completer || autocompleteOpts.insertingSuggestion || + autocompleteOpts.doNotAutocomplete) { + autocompleteOpts.insertingSuggestion = false; + return; + } + let 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). 4 comes from the popup's left padding. + + let cursorElement = cm.display.cursorDiv.querySelector(".CodeMirror-cursor"); + let left = suggestions[0].preLabel.length * cm.defaultCharWidth() + 4; + 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; + }).then(null, e => console.error(e)); +} + +/** + * Inserts a popup item into the current cursor location + * in the editor. + */ +function insertPopupItem(ed, popupItem) { + let {preLabel, text} = popupItem; + let cur = ed.getCursor(); + let textBeforeCursor = ed.getText(cur.line).substring(0, cur.ch); + let backwardsTextBeforeCursor = textBeforeCursor.split("").reverse().join(""); + let 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) { + let autocompleteOpts = autocompleteMap.get(ed); + let { popup } = autocompleteOpts; + let 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 { + let 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) { + let 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) { + 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)) { + let completer = autocompleteMap.get(ed).completer; + if (completer && 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; |