react-mentions icon indicating copy to clipboard operation
react-mentions copied to clipboard

Cursor jumps to end of textarea

Open tgreen7 opened this issue 3 years ago • 14 comments

from #106

I am using react-mentions with redux form.

cursor jump

here is a demo of the broken input https://codesandbox.io/s/react-mentions-input-jump-rm8d6?file=/MentionField.js This would be the ideal approach.

here is a demo of the workaround from the issue mentioned above https://codesandbox.io/s/react-mentions-input-jump-fix-l2yxs?file=/MentionField.js

The workaround is not ideal because it leads to some out of sync state. We will sometimes see bugs in our test runs where the input's text will not match what was typed. The react-mentions input should be able to handle the fully controlled input directly from redux form without the cursor jump issue.

tgreen7 avatar Mar 05 '21 00:03 tgreen7

If you change this to always run in componentDidUpdate the cursor does not jump. Not sure what that would break...

https://github.com/signavio/react-mentions/blob/88037190a39306685b1c2718970ce1149bb3cc00/src/MentionsInput.js#L157

tgreen7 avatar Mar 05 '21 02:03 tgreen7

If anyone from the ClojureScript/Reagent/Re-Frame world made it here. Here's a solution to avoid deferred rendering by reagent (which causes cursor-weirdness)

;;; in your :require ["react-mentions" :refer [MentionsInput Mention]]
[:> MentionsInput
 {:value         @(rf/subscribe [::subs/mention])           ;; current value
  :on-change    (fn [ev new-value text-value]
                  (rf/dispatch-sync [::events/set-mention new-value]) ;; set value in app-db right away
                  (r/flush)) ;; repaint immediately
  :style          basic-style}
 [:> Mention
  {:style            {:color "blue" 
                      :text-decoration :underline
                      :text-shadow "1px 1px 1px white, 1px -1px 1px white, -1px 1px 1px white, -1px -1px 1px white"}
   :trigger          ":"
   :appendSpaceOnAdd true
   :data             [{:id      ":han-solo"
                       :display "Han Solo"}
                      {:id      ":chewbacca"
                       :display "Chewbacca"}]}]]

beders avatar Nov 07 '21 17:11 beders

@tgreen7 any update on this?

aneeskodappana avatar May 31 '22 13:05 aneeskodappana

@aneeskodappana I ended up duplicating the Mention code into our app with the change I mention above. Not ideal but no issues since.

tgreen7 avatar May 31 '22 14:05 tgreen7

@tgreen7 Hi, Im new in react and cannot force it to work. Can you describe more detaily how to fix this issue?

yurii-mamchur avatar Jul 21 '22 13:07 yurii-mamchur

Can i use it on my existing text area ? also can the "display be changed " to username or something ? as i am using an existing data

Abdulaleemu avatar Jul 22 '22 17:07 Abdulaleemu

@yurii-mamchur

here is my new MentionsInput.js

import React, { Children } from "react";
import {
  applyChangeToValue,
  countSuggestions,
  escapeRegex,
  findStartOfMentionInPlainText,
  getEndOfLastMention,
  getMentions,
  getPlainText,
  getSubstringIndex,
  makeMentionsMarkup,
  mapPlainTextIndex,
  readConfigFromChildren,
  spliceString
} from "./utils";

import Highlighter from "./Highlighter";
import PropTypes from "prop-types";
import ReactDOM from "react-dom";
import SuggestionsOverlay from "./SuggestionsOverlay";
import { defaultStyle } from "substyle";
import isEqual from "lodash/isEqual";
import isNumber from "lodash/isNumber";
import keys from "lodash/keys";
import omit from "lodash/omit";
import values from "lodash/values";

export const makeTriggerRegex = function(trigger, options = {}) {
  if (trigger instanceof RegExp) {
    return trigger;
  } else {
    const { allowSpaceInQuery } = options;
    const escapedTriggerChar = escapeRegex(trigger);

    // first capture group is the part to be replaced on completion
    // second capture group is for extracting the search query
    return new RegExp(
      `(?:^|\\s)(${escapedTriggerChar}([^${
        allowSpaceInQuery ? "" : "\\s"
      }${escapedTriggerChar}]*))$`
    );
  }
};

const getDataProvider = function(data, ignoreAccents) {
  if (data instanceof Array) {
    // if data is an array, create a function to query that
    return function(query) {
      const results = [];
      for (let i = 0, l = data.length; i < l; ++i) {
        const display = data[i].display || data[i].id;
        if (getSubstringIndex(display, query, ignoreAccents) >= 0) {
          results.push(data[i]);
        }
      }
      return results;
    };
  } else {
    // expect data to be a query function
    return data;
  }
};

const KEY = { TAB: 9, RETURN: 13, ESC: 27, UP: 38, DOWN: 40 };

let isComposing = false;

const propTypes = {
  /**
   * If set to `true` a regular text input element will be rendered
   * instead of a textarea
   */
  singleLine: PropTypes.bool,
  allowSpaceInQuery: PropTypes.bool,
  EXPERIMENTAL_cutCopyPaste: PropTypes.bool,
  allowSuggestionsAboveCursor: PropTypes.bool,
  ignoreAccents: PropTypes.bool,

  value: PropTypes.string,
  onKeyDown: PropTypes.func,
  onSelect: PropTypes.func,
  onBlur: PropTypes.func,
  onChange: PropTypes.func,
  suggestionsPortalHost:
    typeof Element === "undefined"
      ? PropTypes.any
      : PropTypes.PropTypes.instanceOf(Element),
  inputRef: PropTypes.oneOfType([
    PropTypes.func,
    PropTypes.shape({
      current:
        typeof Element === "undefined"
          ? PropTypes.any
          : PropTypes.instanceOf(Element)
    })
  ]),

  children: PropTypes.oneOfType([
    PropTypes.element,
    PropTypes.arrayOf(PropTypes.element)
  ]).isRequired
};

class MentionsInput extends React.Component {
  static propTypes = propTypes;

  static defaultProps = {
    ignoreAccents: false,
    singleLine: false,
    allowSuggestionsAboveCursor: false,
    onKeyDown: () => null,
    onSelect: () => null,
    onBlur: () => null
  };

  constructor(props) {
    super(props);
    this.suggestions = {};

    this.handleCopy = this.handleCopy.bind(this);
    this.handleCut = this.handleCut.bind(this);
    this.handlePaste = this.handlePaste.bind(this);

    this.state = {
      focusIndex: 0,

      selectionStart: null,
      selectionEnd: null,

      suggestions: {},

      caretPosition: null,
      suggestionsPosition: null
    };
  }

  componentDidMount() {
    const { EXPERIMENTAL_cutCopyPaste } = this.props;

    if (EXPERIMENTAL_cutCopyPaste) {
      document.addEventListener("copy", this.handleCopy);
      document.addEventListener("cut", this.handleCut);
      document.addEventListener("paste", this.handlePaste);
    }

    this.updateSuggestionsPosition();
  }

  componentDidUpdate(prevProps, prevState) {
    // Update position of suggestions unless this componentDidUpdate was
    // triggered by an update to suggestionsPosition.
    if (prevState.suggestionsPosition === this.state.suggestionsPosition) {
      this.updateSuggestionsPosition();
    }

    // maintain selection in case a mention is added/removed causing
    // the cursor to jump to the end
    if (this.state.setSelectionAfterMentionChange) {
      this.setState({ setSelectionAfterMentionChange: false });
    }
    this.setSelection(this.state.selectionStart, this.state.selectionEnd);
  }

  componentWillUnmount() {
    const { EXPERIMENTAL_cutCopyPaste } = this.props;

    if (EXPERIMENTAL_cutCopyPaste) {
      document.removeEventListener("copy", this.handleCopy);
      document.removeEventListener("cut", this.handleCut);
      document.removeEventListener("paste", this.handlePaste);
    }
  }

  render() {
    return (
      <div
        ref={el => {
          this.containerRef = el;
        }}
        {...this.props.style}
      >
        {this.renderControl()}
        {this.renderSuggestionsOverlay()}
      </div>
    );
  }

  getInputProps = () => {
    let { readOnly, disabled, style } = this.props;

    // pass all props that we don't use through to the input control
    let props = omit(this.props, "style", keys(propTypes));

    return {
      ...props,
      ...style("input"),

      value: this.getPlainText(),

      ...(!readOnly &&
        !disabled && {
          onChange: this.handleChange,
          onSelect: this.handleSelect,
          onKeyDown: this.handleKeyDown,
          onBlur: this.handleBlur,
          onCompositionStart: this.handleCompositionStart,
          onCompositionEnd: this.handleCompositionEnd,
          onScroll: this.updateHighlighterScroll
        })
    };
  };

  renderControl = () => {
    let { singleLine, style } = this.props;
    let inputProps = this.getInputProps(!singleLine);

    return (
      <div {...style("control")}>
        {this.renderHighlighter(inputProps.style)}
        {singleLine
          ? this.renderInput(inputProps)
          : this.renderTextarea(inputProps)}
      </div>
    );
  };

  renderInput = props => {
    return <input type="text" ref={this.setInputRef} {...props} />;
  };

  renderTextarea = props => {
    return <textarea ref={this.setInputRef} {...props} />;
  };

  setInputRef = el => {
    this.inputRef = el;
    const { inputRef } = this.props;
    if (typeof inputRef === "function") {
      inputRef(el);
    } else if (inputRef) {
      inputRef.current = el;
    }
  };

  renderSuggestionsOverlay = () => {
    if (!isNumber(this.state.selectionStart)) {
      // do not show suggestions when the input does not have the focus
      return null;
    }

    const suggestionsNode = (
      <SuggestionsOverlay
        style={this.props.style("suggestions")}
        position={this.state.suggestionsPosition}
        focusIndex={this.state.focusIndex}
        scrollFocusedIntoView={this.state.scrollFocusedIntoView}
        ref={el => {
          this.suggestionsRef = el;
        }}
        suggestions={this.state.suggestions}
        onSelect={this.addMention}
        onMouseDown={this.handleSuggestionsMouseDown}
        onMouseEnter={focusIndex =>
          this.setState({
            focusIndex,
            scrollFocusedIntoView: false
          })
        }
        isLoading={this.isLoading()}
        ignoreAccents={this.props.ignoreAccents}
      >
        {this.props.children}
      </SuggestionsOverlay>
    );
    if (this.props.suggestionsPortalHost) {
      return ReactDOM.createPortal(
        suggestionsNode,
        this.props.suggestionsPortalHost
      );
    } else {
      return suggestionsNode;
    }
  };

  renderHighlighter = inputStyle => {
    const { selectionStart, selectionEnd } = this.state;
    const { singleLine, children, value, style } = this.props;

    return (
      <Highlighter
        ref={el => {
          this.highlighterRef = el;
        }}
        style={style("highlighter")}
        inputStyle={inputStyle}
        value={value}
        singleLine={singleLine}
        selection={{
          start: selectionStart,
          end: selectionEnd
        }}
        onCaretPositionChange={position =>
          this.setState({ caretPosition: position })
        }
      >
        {children}
      </Highlighter>
    );
  };

  // Returns the text to set as the value of the textarea with all markups removed
  getPlainText = () => {
    return getPlainText(
      this.props.value || "",
      readConfigFromChildren(this.props.children)
    );
  };

  executeOnChange = (event, ...args) => {
    if (this.props.onChange) {
      return this.props.onChange(event, ...args);
    }

    if (this.props.valueLink) {
      return this.props.valueLink.requestChange(event.target.value, ...args);
    }
  };

  handlePaste(event) {
    if (event.target !== this.inputRef) {
      return;
    }
    if (!this.supportsClipboardActions(event)) {
      return;
    }

    event.preventDefault();

    const { selectionStart, selectionEnd } = this.state;
    const { value, children } = this.props;

    const config = readConfigFromChildren(children);

    const markupStartIndex = mapPlainTextIndex(
      value,
      config,
      selectionStart,
      "START"
    );
    const markupEndIndex = mapPlainTextIndex(
      value,
      config,
      selectionEnd,
      "END"
    );

    const pastedMentions = event.clipboardData.getData("text/react-mentions");
    const pastedData = event.clipboardData.getData("text/plain");

    const newValue = spliceString(
      value,
      markupStartIndex,
      markupEndIndex,
      pastedMentions || pastedData
    ).replace(/\r/g, "");

    const newPlainTextValue = getPlainText(newValue, config);

    const eventMock = { target: { ...event.target, value: newValue } };

    this.executeOnChange(
      eventMock,
      newValue,
      newPlainTextValue,
      getMentions(newValue, config)
    );
  }

  saveSelectionToClipboard(event) {
    const { selectionStart, selectionEnd } = this.state;
    const { children, value } = this.props;

    const config = readConfigFromChildren(children);

    const markupStartIndex = mapPlainTextIndex(
      value,
      config,
      selectionStart,
      "START"
    );
    const markupEndIndex = mapPlainTextIndex(
      value,
      config,
      selectionEnd,
      "END"
    );

    event.clipboardData.setData(
      "text/plain",
      event.target.value.slice(selectionStart, selectionEnd)
    );
    event.clipboardData.setData(
      "text/react-mentions",
      value.slice(markupStartIndex, markupEndIndex)
    );
  }

  supportsClipboardActions(event) {
    return !!event.clipboardData;
  }

  handleCopy(event) {
    if (event.target !== this.inputRef) {
      return;
    }
    if (!this.supportsClipboardActions(event)) {
      return;
    }

    event.preventDefault();

    this.saveSelectionToClipboard(event);
  }

  handleCut(event) {
    if (event.target !== this.inputRef) {
      return;
    }
    if (!this.supportsClipboardActions(event)) {
      return;
    }

    event.preventDefault();

    this.saveSelectionToClipboard(event);

    const { selectionStart, selectionEnd } = this.state;
    const { children, value } = this.props;

    const config = readConfigFromChildren(children);

    const markupStartIndex = mapPlainTextIndex(
      value,
      config,
      selectionStart,
      "START"
    );
    const markupEndIndex = mapPlainTextIndex(
      value,
      config,
      selectionEnd,
      "END"
    );

    const newValue = [
      value.slice(0, markupStartIndex),
      value.slice(markupEndIndex)
    ].join("");
    const newPlainTextValue = getPlainText(newValue, config);

    const eventMock = { target: { ...event.target, value: newPlainTextValue } };

    this.executeOnChange(
      eventMock,
      newValue,
      newPlainTextValue,
      getMentions(value, config)
    );
  }

  // Handle input element's change event
  handleChange = ev => {
    // if we are inside iframe, we need to find activeElement within its contentDocument
    const currentDocument =
      (document.activeElement && document.activeElement.contentDocument) ||
      document;
    if (currentDocument.activeElement !== ev.target) {
      // fix an IE bug (blur from empty input element with placeholder attribute trigger "input" event)
      return;
    }

    const value = this.props.value || "";
    const config = readConfigFromChildren(this.props.children);

    let newPlainTextValue = ev.target.value;

    // Derive the new value to set by applying the local change in the textarea's plain text
    let newValue = applyChangeToValue(
      value,
      newPlainTextValue,
      {
        selectionStartBefore: this.state.selectionStart,
        selectionEndBefore: this.state.selectionEnd,
        selectionEndAfter: ev.target.selectionEnd
      },
      config
    );

    // In case a mention is deleted, also adjust the new plain text value
    newPlainTextValue = getPlainText(newValue, config);

    // Save current selection after change to be able to restore caret position after rerendering
    let selectionStart = ev.target.selectionStart;
    let selectionEnd = ev.target.selectionEnd;
    let setSelectionAfterMentionChange = false;

    // Adjust selection range in case a mention will be deleted by the characters outside of the
    // selection range that are automatically deleted
    let startOfMention = findStartOfMentionInPlainText(
      value,
      config,
      selectionStart
    );

    if (
      startOfMention !== undefined &&
      this.state.selectionEnd > startOfMention
    ) {
      // only if a deletion has taken place
      selectionStart = startOfMention;
      selectionEnd = selectionStart;
      setSelectionAfterMentionChange = true;
    }

    this.setState({
      selectionStart,
      selectionEnd,
      setSelectionAfterMentionChange: setSelectionAfterMentionChange
    });

    let mentions = getMentions(newValue, config);

    // Propagate change
    // let handleChange = this.getOnChange(this.props) || emptyFunction;
    let eventMock = { target: { value: newValue } };
    // this.props.onChange.call(this, eventMock, newValue, newPlainTextValue, mentions);
    this.executeOnChange(eventMock, newValue, newPlainTextValue, mentions);
  };

  // Handle input element's select event
  handleSelect = ev => {
    // keep track of selection range / caret position
    this.setState({
      selectionStart: ev.target.selectionStart,
      selectionEnd: ev.target.selectionEnd
    });

    // do nothing while a IME composition session is active
    if (isComposing) return;

    // refresh suggestions queries
    const el = this.inputRef;
    if (ev.target.selectionStart === ev.target.selectionEnd) {
      this.updateMentionsQueries(el.value, ev.target.selectionStart);
    } else {
      this.clearSuggestions();
    }

    // sync highlighters scroll position
    this.updateHighlighterScroll();

    this.props.onSelect(ev);
  };

  handleKeyDown = ev => {
    // do not intercept key events if the suggestions overlay is not shown
    const suggestionsCount = countSuggestions(this.state.suggestions);

    const suggestionsComp = this.suggestionsRef;
    if (suggestionsCount === 0 || !suggestionsComp) {
      this.props.onKeyDown(ev);

      return;
    }

    if (values(KEY).indexOf(ev.keyCode) >= 0) {
      ev.preventDefault();
    }

    switch (ev.keyCode) {
      case KEY.ESC: {
        this.clearSuggestions();
        return;
      }
      case KEY.DOWN: {
        this.shiftFocus(+1);
        return;
      }
      case KEY.UP: {
        this.shiftFocus(-1);
        return;
      }
      case KEY.RETURN: {
        this.selectFocused();
        return;
      }
      case KEY.TAB: {
        this.selectFocused();
        return;
      }
      default: {
        return;
      }
    }
  };

  shiftFocus = delta => {
    const suggestionsCount = countSuggestions(this.state.suggestions);

    this.setState({
      focusIndex:
        (suggestionsCount + this.state.focusIndex + delta) % suggestionsCount,
      scrollFocusedIntoView: true
    });
  };

  selectFocused = () => {
    const { suggestions, focusIndex } = this.state;

    const { result, queryInfo } = Object.values(suggestions).reduce(
      (acc, { results, queryInfo }) => [
        ...acc,
        ...results.map(result => ({ result, queryInfo }))
      ],
      []
    )[focusIndex];

    this.addMention(result, queryInfo);

    this.setState({
      focusIndex: 0
    });
  };

  handleBlur = ev => {
    const clickedSuggestion = this._suggestionsMouseDown;
    this._suggestionsMouseDown = false;

    // only reset selection if the mousedown happened on an element
    // other than the suggestions overlay
    if (!clickedSuggestion) {
      this.setState({
        selectionStart: null,
        selectionEnd: null
      });
    }

    window.setTimeout(() => {
      this.updateHighlighterScroll();
    }, 1);

    this.props.onBlur(ev, clickedSuggestion);
  };

  handleSuggestionsMouseDown = () => {
    this._suggestionsMouseDown = true;
  };

  updateSuggestionsPosition = () => {
    let { caretPosition } = this.state;
    const { suggestionsPortalHost, allowSuggestionsAboveCursor } = this.props;

    if (!caretPosition || !this.suggestionsRef) {
      return;
    }

    let suggestions = ReactDOM.findDOMNode(this.suggestionsRef);
    let highlighter = ReactDOM.findDOMNode(this.highlighterRef);
    // first get viewport-relative position (highlighter is offsetParent of caret):
    const caretOffsetParentRect = highlighter.getBoundingClientRect();
    const caretHeight = getComputedStyleLengthProp(highlighter, "font-size");
    const viewportRelative = {
      left: caretOffsetParentRect.left + caretPosition.left,
      top: caretOffsetParentRect.top + caretPosition.top + caretHeight
    };
    const viewportHeight = Math.max(
      document.documentElement.clientHeight,
      window.innerHeight || 0
    );

    if (!suggestions) {
      return;
    }

    let position = {};

    // if suggestions menu is in a portal, update position to be releative to its portal node
    if (suggestionsPortalHost) {
      position.position = "fixed";
      let left = viewportRelative.left;
      let top = viewportRelative.top;
      // absolute/fixed positioned elements are positioned according to their entire box including margins; so we remove margins here:
      left -= getComputedStyleLengthProp(suggestions, "margin-left");
      top -= getComputedStyleLengthProp(suggestions, "margin-top");
      // take into account highlighter/textinput scrolling:
      left -= highlighter.scrollLeft;
      top -= highlighter.scrollTop;
      // guard for mentions suggestions list clipped by right edge of window
      const viewportWidth = Math.max(
        document.documentElement.clientWidth,
        window.innerWidth || 0
      );
      if (left + suggestions.offsetWidth > viewportWidth) {
        position.left = Math.max(0, viewportWidth - suggestions.offsetWidth);
      } else {
        position.left = left;
      }
      // guard for mentions suggestions list clipped by bottom edge of window if allowSuggestionsAboveCursor set to true.
      // Move the list up above the caret if it's getting cut off by the bottom of the window, provided that the list height
      // is small enough to NOT cover up the caret
      if (
        allowSuggestionsAboveCursor &&
        top + suggestions.offsetHeight > viewportHeight &&
        suggestions.offsetHeight < top - caretHeight
      ) {
        position.top = Math.max(
          0,
          top - suggestions.offsetHeight - caretHeight
        );
      } else {
        position.top = top;
      }
    } else {
      let left = caretPosition.left - highlighter.scrollLeft;
      let top = caretPosition.top - highlighter.scrollTop;
      // guard for mentions suggestions list clipped by right edge of window
      if (left + suggestions.offsetWidth > this.containerRef.offsetWidth) {
        position.right = 0;
      } else {
        position.left = left;
      }
      // guard for mentions suggestions list clipped by bottom edge of window if allowSuggestionsAboveCursor set to true.
      // move the list up above the caret if it's getting cut off by the bottom of the window, provided that the list height
      // is small enough to NOT cover up the caret
      if (
        allowSuggestionsAboveCursor &&
        viewportRelative.top -
          highlighter.scrollTop +
          suggestions.offsetHeight >
          viewportHeight &&
        suggestions.offsetHeight <
          caretOffsetParentRect.top - caretHeight - highlighter.scrollTop
      ) {
        position.top = top - suggestions.offsetHeight - caretHeight;
      } else {
        position.top = top;
      }
    }

    if (isEqual(position, this.state.suggestionsPosition)) {
      return;
    }

    this.setState({
      suggestionsPosition: position
    });
  };

  updateHighlighterScroll = () => {
    if (!this.inputRef || !this.highlighterRef) {
      // since the invocation of this function is deferred,
      // the whole component may have been unmounted in the meanwhile
      return;
    }
    const input = this.inputRef;
    const highlighter = ReactDOM.findDOMNode(this.highlighterRef);
    highlighter.scrollLeft = input.scrollLeft;
    highlighter.scrollTop = input.scrollTop;
    highlighter.height = input.height;
  };

  handleCompositionStart = () => {
    isComposing = true;
  };

  handleCompositionEnd = () => {
    isComposing = false;
  };

  setSelection = (selectionStart, selectionEnd) => {
    if (selectionStart === null || selectionEnd === null) return;

    const el = this.inputRef;
    if (el.setSelectionRange) {
      el.setSelectionRange(selectionStart, selectionEnd);
    } else if (el.createTextRange) {
      const range = el.createTextRange();
      range.collapse(true);
      range.moveEnd("character", selectionEnd);
      range.moveStart("character", selectionStart);
      range.select();
    }
  };

  updateMentionsQueries = (plainTextValue, caretPosition) => {
    // Invalidate previous queries. Async results for previous queries will be neglected.
    this._queryId++;
    this.suggestions = {};
    this.setState({
      suggestions: {}
    });

    const value = this.props.value || "";
    const { children } = this.props;
    const config = readConfigFromChildren(children);

    const positionInValue = mapPlainTextIndex(
      value,
      config,
      caretPosition,
      "NULL"
    );

    // If caret is inside of mention, do not query
    if (positionInValue === null) {
      return;
    }

    // Extract substring in between the end of the previous mention and the caret
    const substringStartIndex = getEndOfLastMention(
      value.substring(0, positionInValue),
      config
    );
    const substring = plainTextValue.substring(
      substringStartIndex,
      caretPosition
    );

    // Check if suggestions have to be shown:
    // Match the trigger patterns of all Mention children on the extracted substring
    React.Children.forEach(children, (child, childIndex) => {
      if (!child) {
        return;
      }

      const regex = makeTriggerRegex(child.props.trigger, this.props);
      const match = substring.match(regex);
      if (match) {
        const querySequenceStart =
          substringStartIndex + substring.indexOf(match[1], match.index);
        this.queryData(
          match[2],
          childIndex,
          querySequenceStart,
          querySequenceStart + match[1].length,
          plainTextValue
        );
      }
    });
  };

  clearSuggestions = () => {
    // Invalidate previous queries. Async results for previous queries will be neglected.
    this._queryId++;
    this.suggestions = {};
    this.setState({
      suggestions: {},
      focusIndex: 0
    });
  };

  queryData = (
    query,
    childIndex,
    querySequenceStart,
    querySequenceEnd,
    plainTextValue
  ) => {
    const { children, ignoreAccents } = this.props;
    const mentionChild = Children.toArray(children)[childIndex];
    const provideData = getDataProvider(mentionChild.props.data, ignoreAccents);
    const syncResult = provideData(
      query,
      this.updateSuggestions.bind(
        null,
        this._queryId,
        childIndex,
        query,
        querySequenceStart,
        querySequenceEnd,
        plainTextValue
      )
    );
    if (syncResult instanceof Array) {
      this.updateSuggestions(
        this._queryId,
        childIndex,
        query,
        querySequenceStart,
        querySequenceEnd,
        plainTextValue,
        syncResult
      );
    }
  };

  updateSuggestions = (
    queryId,
    childIndex,
    query,
    querySequenceStart,
    querySequenceEnd,
    plainTextValue,
    results
  ) => {
    // neglect async results from previous queries
    if (queryId !== this._queryId) return;

    // save in property so that multiple sync state updates from different mentions sources
    // won't overwrite each other
    this.suggestions = {
      ...this.suggestions,
      [childIndex]: {
        queryInfo: {
          childIndex,
          query,
          querySequenceStart,
          querySequenceEnd,
          plainTextValue
        },
        results
      }
    };

    const { focusIndex } = this.state;
    const suggestionsCount = countSuggestions(this.suggestions);
    this.setState({
      suggestions: this.suggestions,
      focusIndex:
        focusIndex >= suggestionsCount
          ? Math.max(suggestionsCount - 1, 0)
          : focusIndex
    });
  };

  addMention = (
    { id, display },
    { childIndex, querySequenceStart, querySequenceEnd, plainTextValue }
  ) => {
    // Insert mention in the marked up value at the correct position
    const value = this.props.value || "";
    const config = readConfigFromChildren(this.props.children);
    const mentionsChild = Children.toArray(this.props.children)[childIndex];
    const {
      markup,
      displayTransform,
      appendSpaceOnAdd,
      onAdd
    } = mentionsChild.props;

    const start = mapPlainTextIndex(value, config, querySequenceStart, "START");
    const end = start + querySequenceEnd - querySequenceStart;
    let insert = makeMentionsMarkup(markup, id, display);
    if (appendSpaceOnAdd) {
      insert += " ";
    }
    const newValue = spliceString(value, start, end, insert);

    // Refocus input and set caret position to end of mention
    this.inputRef.focus();

    let displayValue = displayTransform(id, display);
    if (appendSpaceOnAdd) {
      displayValue += " ";
    }
    const newCaretPosition = querySequenceStart + displayValue.length;
    this.setState({
      selectionStart: newCaretPosition,
      selectionEnd: newCaretPosition,
      setSelectionAfterMentionChange: true
    });

    // Propagate change
    const eventMock = { target: { value: newValue } };
    const mentions = getMentions(newValue, config);
    const newPlainTextValue = spliceString(
      plainTextValue,
      querySequenceStart,
      querySequenceEnd,
      displayValue
    );

    this.executeOnChange(eventMock, newValue, newPlainTextValue, mentions);

    if (onAdd) {
      onAdd(id, display);
    }

    // Make sure the suggestions overlay is closed
    this.clearSuggestions();
  };

  isLoading = () => {
    let isLoading = false;
    React.Children.forEach(this.props.children, function(child) {
      isLoading = isLoading || (child && child.props.isLoading);
    });
    return isLoading;
  };

  _queryId = 0;
}

/**
 * Returns the computed length property value for the provided element.
 * Note: According to spec and testing, can count on length values coming back in pixels. See https://developer.mozilla.org/en-US/docs/Web/CSS/used_value#Difference_from_computed_value
 */
const getComputedStyleLengthProp = (forElement, propertyName) => {
  const length = parseFloat(
    window.getComputedStyle(forElement, null).getPropertyValue(propertyName)
  );
  return isFinite(length) ? length : 0;
};

const isMobileSafari =
  typeof navigator !== "undefined" &&
  /iPhone|iPad|iPod/i.test(navigator.userAgent);

const styled = defaultStyle(
  {
    position: "relative",
    overflowY: "visible",

    input: {
      display: "block",
      position: "absolute",
      top: 0,
      left: 0,
      boxSizing: "border-box",
      backgroundColor: "transparent",
      width: "inherit",
      fontFamily: "inherit",
      fontSize: "inherit",
      letterSpacing: "inherit"
    },

    "&multiLine": {
      input: {
        width: "100%",
        height: "100%",
        bottom: 0,
        overflow: "hidden",
        resize: "none",

        // fix weird textarea padding in mobile Safari (see: http://stackoverflow.com/questions/6890149/remove-3-pixels-in-ios-webkit-textarea)
        ...(isMobileSafari
          ? {
              marginTop: 1,
              marginLeft: -3
            }
          : null)
      }
    }
  },
  ({ singleLine }) => ({
    "&singleLine": singleLine,
    "&multiLine": !singleLine
  })
);

export default styled(MentionsInput);

so what you would need to do to use this is basically copy this whole repo into your project and then change the file above. Very much not ideal. It would be better to get this fixed in this repo instead.

tgreen7 avatar Jul 22 '22 19:07 tgreen7

thanks a... ill update you how it goes

Abdulaleemu avatar Jul 27 '22 06:07 Abdulaleemu

Yeah unfortunately It seems my version and the react-mentions repo have diverged a bunch. I'll look into forking and opening a pr with my change and see if it is accepted. Not sure if @Sigrsig has a better suggestion.

tgreen7 avatar Jul 27 '22 07:07 tgreen7

alright Boss thanks a million

Abdulaleemu avatar Jul 27 '22 08:07 Abdulaleemu

i am using it as my main input field,.. is there a way to use emojis on the input field.. thank you

Abdulaleemu avatar Jul 28 '22 12:07 Abdulaleemu

getting this error but it goes away when i remove the callback Unhandled Runtime Error TypeError: Cannot read properties of undefined (reading 'length')

below is my code ..#

function fetchUsers(query, callback) { if (!query) return;

axios .get(/api/v1/tag?name=${query}) .then(function (response) { // handle success

  response.data.data
    .map((user) => ({ display: user.name, id: user.id }))
    .filter((person) => person.display.toLowerCase().includes(query));
})
 .then(callback);

}

Abdulaleemu avatar Jul 29 '22 07:07 Abdulaleemu

i am able to fix it also added emoji... thank you

Abdulaleemu avatar Jul 29 '22 11:07 Abdulaleemu

thanks a lot @beders I did make it to this issue as a cljs dev using reagent and I would never have found the fix. Nice end of the year gift 🥳

ioRekz avatar Dec 27 '22 17:12 ioRekz