tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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;