autocomplete.js (10229B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); 8 9 loader.lazyRequireGetter( 10 this, 11 "KeyCodes", 12 "resource://devtools/client/shared/keycodes.js", 13 true 14 ); 15 loader.lazyRequireGetter( 16 this, 17 "CSSCompleter", 18 "resource://devtools/client/shared/sourceeditor/css-autocompleter.js" 19 ); 20 21 const autocompleteMap = new WeakMap(); 22 23 /** 24 * Prepares an editor instance for autocompletion. 25 */ 26 function initializeAutoCompletion(ctx, options = {}) { 27 const { cm, ed, Editor } = ctx; 28 if (autocompleteMap.has(ed)) { 29 return; 30 } 31 32 const win = ed.container.contentWindow.wrappedJSObject; 33 const { CodeMirror } = win; 34 35 let completer = null; 36 const autocompleteKey = 37 "Ctrl-" + Editor.keyFor("autocompletion", { noaccel: true }); 38 if (ed.config.mode == Editor.modes.css) { 39 completer = new CSSCompleter({ 40 walker: options.walker, 41 cssProperties: options.cssProperties, 42 }); 43 } 44 45 function insertSelectedPopupItem() { 46 const autocompleteState = autocompleteMap.get(ed); 47 if (!popup || !popup.isOpen || !autocompleteState) { 48 return false; 49 } 50 51 if (!autocompleteState.suggestionInsertedOnce && popup.selectedItem) { 52 autocompleteMap.get(ed).insertingSuggestion = true; 53 insertPopupItem(ed, popup.selectedItem); 54 } 55 56 popup.once("popup-closed", () => { 57 // This event is used in tests. 58 ed.emit("popup-hidden"); 59 }); 60 popup.hidePopup(); 61 return true; 62 } 63 64 // Give each popup a new name to avoid sharing the elements. 65 66 let popup = new AutocompletePopup(win.parent.document, { 67 position: "bottom", 68 autoSelect: true, 69 onClick: insertSelectedPopupItem, 70 }); 71 72 const cycle = reverse => { 73 if (popup?.isOpen) { 74 // eslint-disable-next-line mozilla/no-compare-against-boolean-literals 75 cycleSuggestions(ed, reverse == true); 76 return null; 77 } 78 79 return CodeMirror.Pass; 80 }; 81 82 let keyMap = { 83 Tab: cycle, 84 Down: cycle, 85 "Shift-Tab": cycle.bind(null, true), 86 Up: cycle.bind(null, true), 87 Enter: () => { 88 const wasHandled = insertSelectedPopupItem(); 89 return wasHandled ? true : CodeMirror.Pass; 90 }, 91 }; 92 93 const autoCompleteCallback = autoComplete.bind(null, ctx); 94 const keypressCallback = onEditorKeypress.bind(null, ctx); 95 keyMap[autocompleteKey] = autoCompleteCallback; 96 cm.addKeyMap(keyMap); 97 98 cm.on("keydown", keypressCallback); 99 ed.on("change", autoCompleteCallback); 100 ed.on("destroy", destroy); 101 102 function destroy() { 103 ed.off("destroy", destroy); 104 cm.off("keydown", keypressCallback); 105 ed.off("change", autoCompleteCallback); 106 cm.removeKeyMap(keyMap); 107 popup.destroy(); 108 keyMap = popup = completer = null; 109 autocompleteMap.delete(ed); 110 } 111 112 autocompleteMap.set(ed, { 113 popup, 114 completer, 115 keyMap, 116 destroy, 117 insertingSuggestion: false, 118 suggestionInsertedOnce: false, 119 }); 120 } 121 122 /** 123 * Destroy autocompletion on an editor instance. 124 */ 125 function destroyAutoCompletion(ctx) { 126 const { ed } = ctx; 127 if (!autocompleteMap.has(ed)) { 128 return; 129 } 130 131 const { destroy } = autocompleteMap.get(ed); 132 destroy(); 133 } 134 135 /** 136 * Provides suggestions to autocomplete the current token/word being typed. 137 */ 138 function autoComplete({ ed, cm }) { 139 const autocompleteOpts = autocompleteMap.get(ed); 140 const { completer, popup } = autocompleteOpts; 141 if ( 142 !completer || 143 autocompleteOpts.insertingSuggestion || 144 autocompleteOpts.doNotAutocomplete 145 ) { 146 autocompleteOpts.insertingSuggestion = false; 147 return; 148 } 149 const cur = ed.getCursor(); 150 completer 151 .complete(cm.getRange({ line: 0, ch: 0 }, cur), cur) 152 .then(suggestions => { 153 if ( 154 !suggestions || 155 !suggestions.length || 156 suggestions[0].preLabel == null 157 ) { 158 autocompleteOpts.suggestionInsertedOnce = false; 159 popup.once("popup-closed", () => { 160 // This event is used in tests. 161 ed.emit("after-suggest"); 162 }); 163 popup.hidePopup(); 164 return; 165 } 166 // The cursor is at the end of the currently entered part of the token, 167 // like "backgr|" but we need to open the popup at the beginning of the 168 // character "b". Thus we need to calculate the width of the entered part 169 // of the token ("backgr" here). 170 171 const cursorElement = 172 cm.display.cursorDiv.querySelector(".CodeMirror-cursor"); 173 const left = suggestions[0].preLabel.length * cm.defaultCharWidth(); 174 popup.hidePopup(); 175 popup.setItems(suggestions); 176 177 popup.once("popup-opened", () => { 178 // This event is used in tests. 179 ed.emit("after-suggest"); 180 }); 181 popup.openPopup(cursorElement, -1 * left, 0); 182 autocompleteOpts.suggestionInsertedOnce = false; 183 }) 184 .catch(console.error); 185 } 186 187 /** 188 * Inserts a popup item into the current cursor location 189 * in the editor. 190 */ 191 function insertPopupItem(ed, popupItem) { 192 const { preLabel, text } = popupItem; 193 const cur = ed.getCursor(); 194 const textBeforeCursor = ed.getText(cur.line).substring(0, cur.ch); 195 const backwardsTextBeforeCursor = textBeforeCursor 196 .split("") 197 .reverse() 198 .join(""); 199 const backwardsPreLabel = preLabel.split("").reverse().join(""); 200 201 // If there is additional text in the preLabel vs the line, then 202 // just insert the entire autocomplete text. An example: 203 // if you type 'a' and select '#about' from the autocomplete menu, 204 // then the final text needs to the end up as '#about'. 205 if (backwardsPreLabel.indexOf(backwardsTextBeforeCursor) === 0) { 206 ed.replaceText(text, { line: cur.line, ch: 0 }, cur); 207 } else { 208 ed.replaceText(text.slice(preLabel.length), cur, cur); 209 } 210 } 211 212 /** 213 * Cycles through provided suggestions by the popup in a top to bottom manner 214 * when `reverse` is not true. Opposite otherwise. 215 */ 216 function cycleSuggestions(ed, reverse) { 217 const autocompleteOpts = autocompleteMap.get(ed); 218 const { popup } = autocompleteOpts; 219 const cur = ed.getCursor(); 220 autocompleteOpts.insertingSuggestion = true; 221 if (!autocompleteOpts.suggestionInsertedOnce) { 222 autocompleteOpts.suggestionInsertedOnce = true; 223 let firstItem; 224 if (reverse) { 225 firstItem = popup.getItemAtIndex(popup.itemCount - 1); 226 popup.selectPreviousItem(); 227 } else { 228 firstItem = popup.getItemAtIndex(0); 229 if (firstItem.label == firstItem.preLabel && popup.itemCount > 1) { 230 firstItem = popup.getItemAtIndex(1); 231 popup.selectNextItem(); 232 } 233 } 234 if (popup.itemCount == 1) { 235 popup.hidePopup(); 236 } 237 insertPopupItem(ed, firstItem); 238 } else { 239 const fromCur = { 240 line: cur.line, 241 ch: cur.ch - popup.selectedItem.text.length, 242 }; 243 if (reverse) { 244 popup.selectPreviousItem(); 245 } else { 246 popup.selectNextItem(); 247 } 248 ed.replaceText(popup.selectedItem.text, fromCur, cur); 249 } 250 // This event is used in tests. 251 ed.emit("suggestion-entered"); 252 } 253 254 /** 255 * onkeydown handler for the editor instance to prevent autocompleting on some 256 * keypresses. 257 */ 258 function onEditorKeypress({ ed, Editor }, cm, event) { 259 const autocompleteOpts = autocompleteMap.get(ed); 260 261 // Do not try to autocomplete with multiple selections. 262 if (ed.hasMultipleSelections()) { 263 autocompleteOpts.doNotAutocomplete = true; 264 autocompleteOpts.popup.hidePopup(); 265 return; 266 } 267 268 if ( 269 (event.ctrlKey || event.metaKey) && 270 event.keyCode == KeyCodes.DOM_VK_SPACE 271 ) { 272 // When Ctrl/Cmd + Space is pressed, two simultaneous keypresses are emitted 273 // first one for just the Ctrl/Cmd and second one for combo. The first one 274 // leave the autocompleteOpts.doNotAutocomplete as true, so we have to make 275 // it false 276 autocompleteOpts.doNotAutocomplete = false; 277 return; 278 } 279 280 if (event.ctrlKey || event.metaKey || event.altKey) { 281 autocompleteOpts.doNotAutocomplete = true; 282 autocompleteOpts.popup.hidePopup(); 283 return; 284 } 285 286 switch (event.keyCode) { 287 case KeyCodes.DOM_VK_RETURN: 288 autocompleteOpts.doNotAutocomplete = true; 289 break; 290 case KeyCodes.DOM_VK_ESCAPE: 291 if (autocompleteOpts.popup.isOpen) { 292 // Prevent the Console input to open, but still remove the autocomplete popup. 293 autocompleteOpts.doNotAutocomplete = true; 294 autocompleteOpts.popup.hidePopup(); 295 event.preventDefault(); 296 } 297 break; 298 case KeyCodes.DOM_VK_LEFT: 299 case KeyCodes.DOM_VK_RIGHT: 300 case KeyCodes.DOM_VK_HOME: 301 case KeyCodes.DOM_VK_END: 302 autocompleteOpts.doNotAutocomplete = true; 303 autocompleteOpts.popup.hidePopup(); 304 break; 305 case KeyCodes.DOM_VK_BACK_SPACE: 306 case KeyCodes.DOM_VK_DELETE: 307 if (ed.config.mode == Editor.modes.css) { 308 autocompleteOpts.completer.invalidateCache(ed.getCursor().line); 309 } 310 autocompleteOpts.doNotAutocomplete = true; 311 autocompleteOpts.popup.hidePopup(); 312 break; 313 default: 314 autocompleteOpts.doNotAutocomplete = false; 315 } 316 } 317 318 /** 319 * Returns the private popup. This method is used by tests to test the feature. 320 */ 321 function getPopup({ ed }) { 322 if (autocompleteMap.has(ed)) { 323 return autocompleteMap.get(ed).popup; 324 } 325 326 return null; 327 } 328 329 /** 330 * Returns contextual information about the token covered by the caret if the 331 * implementation of completer supports it. 332 */ 333 function getInfoAt({ ed }, caret) { 334 if (autocompleteMap.has(ed)) { 335 const completer = autocompleteMap.get(ed).completer; 336 if (completer?.getInfoAt) { 337 return completer.getInfoAt(ed.getText(), caret); 338 } 339 } 340 341 return null; 342 } 343 344 /** 345 * Returns whether autocompletion is enabled for this editor. 346 * Used for testing 347 */ 348 function isAutocompletionEnabled({ ed }) { 349 return autocompleteMap.has(ed); 350 } 351 352 // Export functions 353 354 module.exports.initializeAutoCompletion = initializeAutoCompletion; 355 module.exports.destroyAutoCompletion = destroyAutoCompletion; 356 module.exports.getAutocompletionPopup = getPopup; 357 module.exports.getInfoAt = getInfoAt; 358 module.exports.isAutocompletionEnabled = isAutocompletionEnabled;