tor-browser

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

suggestions.js (5449B)


      1 import { Plugin, PluginKey } from 'prosemirror-state';
      2 import { Decoration, DecorationSet } from 'prosemirror-view';
      3 
      4 /**
      5 * Create a matcher that matches when a specific character is typed. Useful for @mentions and #tags.
      6 *
      7 * @param {String} char
      8 * @param {Boolean} allowSpaces
      9 * @returns {function(*)}
     10 */
     11 export function triggerCharacter(char, { allowSpaces = false }) {
     12  /**
     13   * @param {ResolvedPos} $position
     14   */
     15  return $position => {
     16    // Matching expressions used for later
     17    const suffix = new RegExp(`\\s${char}$`);
     18    const regexp = allowSpaces
     19      ? new RegExp(`${char}.*?(?=\\s${char}|$)`, 'g')
     20      : new RegExp(`(?:^)?${char}[^\\s${char}]*`, 'g');
     21 
     22    // Lookup the boundaries of the current node
     23    const textFrom = $position.before();
     24    const textTo = $position.end();
     25 
     26    const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0');
     27 
     28    let match;
     29 
     30    while (match = regexp.exec(text)) {
     31      // Javascript doesn't have lookbehinds; this hacks a check that first character is " " or the line beginning
     32      const prefix = match.input.slice(Math.max(0, match.index - 1), match.index);
     33      if (!/^[\s\0]?$/.test(prefix)) {
     34        continue;
     35      }
     36 
     37      // The absolute position of the match in the document
     38      const from = match.index + $position.start();
     39      let to = from + match[0].length;
     40 
     41      // Edge case handling; if spaces are allowed and we're directly in between two triggers
     42      if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
     43        match[0] += ' ';
     44        to++;
     45      }
     46 
     47      // If the $position is located within the matched substring, return that range
     48      if (from < $position.pos && to >= $position.pos) {
     49        return { range: { from, to }, text: match[0] };
     50      }
     51    }
     52  }
     53 }
     54 
     55 /**
     56 * @returns {Plugin}
     57 */
     58 export function suggestionsPlugin({
     59  matcher = triggerCharacter('#'),
     60  suggestionClass = 'ProseMirror-suggestion',
     61  onEnter = () => false,
     62  onChange = () => false,
     63  onExit = () => false,
     64  onKeyDown = () => false,
     65  debug = false,
     66 }) {
     67  return new Plugin({
     68    key: new PluginKey('suggestions'),
     69 
     70    view() {
     71      return {
     72        update: (view, prevState) => {
     73          const prev = this.key.getState(prevState);
     74          const next = this.key.getState(view.state);
     75 
     76          // See how the state changed
     77          const moved = prev.active && next.active && prev.range.from !== next.range.from;
     78          const started = !prev.active && next.active;
     79          const stopped = prev.active && !next.active;
     80          const changed = !started && !stopped && prev.text !== next.text;
     81 
     82          // Trigger the hooks when necessary
     83          if (stopped || moved) onExit({ view, range: prev.range, text: prev.text });
     84          if (changed && !moved) onChange({ view, range: next.range, text: next.text });
     85          if (started || moved) onEnter({ view, range: next.range, text: next.text });
     86        },
     87      };
     88    },
     89 
     90    state: {
     91      /**
     92       * Initialize the plugin's internal state.
     93       *
     94       * @returns {Object}
     95       */
     96      init() {
     97        return {
     98          active: false,
     99          range: {},
    100          text: null,
    101        };
    102      },
    103 
    104      /**
    105       * Apply changes to the plugin state from a view transaction.
    106       *
    107       * @param {Transaction} tr
    108       * @param {Object} prev
    109       *
    110       * @returns {Object}
    111       */
    112      apply(tr, prev) {
    113        const { selection } = tr;
    114        const next = { ...prev };
    115 
    116        // We can only be suggesting if there is no selection
    117        if (selection.from === selection.to) {
    118          // Reset active state if we just left the previous suggestion range
    119          if (selection.from < prev.range.from || selection.from > prev.range.to) {
    120            next.active = false;
    121          }
    122 
    123          // Try to match against where our cursor currently is
    124          const $position = selection.$from;
    125          const match = matcher($position);
    126 
    127          // If we found a match, update the current state to show it
    128          if (match) {
    129            next.active = true;
    130            next.range = match.range;
    131            next.text = match.text;
    132          } else {
    133            next.active = false;
    134          }
    135        } else {
    136          next.active = false;
    137        }
    138 
    139        // Make sure to empty the range if suggestion is inactive
    140        if (!next.active) {
    141          next.range = {};
    142          next.text = null;
    143        }
    144 
    145        return next;
    146      },
    147    },
    148 
    149    props: {
    150      /**
    151       * Call the keydown hook if suggestion is active.
    152       *
    153       * @param view
    154       * @param event
    155       * @returns {boolean}
    156       */
    157      handleKeyDown(view, event) {
    158        const { active } = this.getState(view.state);
    159 
    160        if (!active) return false;
    161 
    162        return onKeyDown({ view, event });
    163      },
    164 
    165      /**
    166       * Setup decorator on the currently active suggestion.
    167       *
    168       * @param {EditorState} editorState
    169       *
    170       * @returns {?DecorationSet}
    171       */
    172      decorations(editorState) {
    173        const { active, range } = this.getState(editorState);
    174 
    175        if (!active) return null;
    176 
    177        return DecorationSet.create(editorState.doc, [
    178          Decoration.inline(range.from, range.to, {
    179            nodeName: 'span',
    180            class: suggestionClass,
    181            style: debug ? 'background: rgba(0, 0, 255, 0.05); color: blue; border: 2px solid blue;' : null,
    182          }),
    183        ]);
    184      },
    185    },
    186  });
    187 }