Wie wir das Feld für die Eingabe neuer Nachrichten in unseren Messenger (Gem4me) entwickelt haben

Hallo alle zusammen!



Mein Name ist Alexander Baltsevich, ich arbeite an der Führungsposition des Web-Teams des Gem4me-Projekts. Das Projekt ist ein innovativer Botschafter für alle und jeden (bisher in meinen Fantasien, aber wir streben danach ;-))


Kurz zum Webversionsstapel: ReactJS (wer würde das bezweifeln) + mobX (persönlich bin ich überhaupt nicht begeistert, aber wir planen keine Migration irgendwo; wenn Sie an den Details interessiert sind, was genau nicht zu Ihnen passt, schreiben Sie Kommentare - vielleicht mache ich einen separaten Artikel darüber) + Storybook + wdio (Screenshot-Test).


Der Messenger ist in erster Linie ein Programm zum Austausch von Kurznachrichten (und erst dann all diese Anrufe, Aufkleber und andere Konferenzen). Nachrichten sind grundlegende Funktionen, die gut funktionieren sollten. Nein, nicht so: es sollte perfekt funktionieren. Und nur derjenige, der mindestens einmal auf die Entwicklung dieser Funktionalität gestoßen ist, kennt alle Schmerzen, die überwunden werden müssen, damit alles schön aussieht. Wir alle haben das mit dem Team durchgemacht. Und wir beschlossen zu teilen.




Bedarf


Welche Funktionen sollte ein Eingabefeld bieten?


  • Es ist einfach, Text zu drucken und in mehreren Zeilen zu schreiben (durch Drücken von Umschalt + Eingabetaste). Fortgeschrittene Benutzer sind es auch gewohnt, Markdown-Zeichen zu verwenden, um Nachrichten schnell zu formatieren.



  • Fügen Sie Text aus dem Puffer ein. Beim Kopieren von Text wird häufig auch der Hintergrund unter diesem Text kopiert (siehe Beispiel unten) und formatiert. Aber sauberer, ordentlicher Text und nichts mehr sollten in das Eingabefeld gelangen.



  • Fügen Sie Bilder aus der Zwischenablage ein. Es scheint mir, dass niemand ohne diese Funktion leben kann.
  • Smilies =) sind längst zu einer Art Teil der Kommunikationskultur geworden. Daher können Sie das Lächeln nicht vergessen. Zu Beginn des Textes, in der Mitte, sollten zehn Teile hintereinander oder einzeln durch drei Wörter, die aus der internen Bibliothek eingefügt oder aus einer anderen Ressource kopiert wurden - in allen Variationen und Variationen - immer in fünf mit einem Pluszeichen gezeichnet werden.
  • , , ( ). — , , . , , . Esc ( ).



" " ("mention") , . :


  • “@“ “ ”. , :



  • "@" ("@T" => "T");
  • “ ” , , "@Tes", ("@Te|s"), . ;
  • , " @ ?", "@" ("| @ ?") "@" (" @ | ?"), “ ” ;
  • , (" @ ?|") — , (" @| ?") — , "@" ;
  • , ("@| "), " " . — , . , , (“@ | ”).

, .



, , , — :


  • , , . , , .
  • . , - — ( ), — - . , . , .

, , — , .., , , .


!


HTML


, HTML . , , , , , , input, textarea , .


input , , .


textarea — " ", . , (. ). input, shift + Enter . 5 , . textarea , , .






, textarea, , , div. , , , ! contenteditable.


The contenteditable global attribute is an enumerated attribute indicating if the element should be editable by the user. If so, the browser modifies its widget to allow editing. (MDN)

, , .


:


<div
  className={styles.input}
  placeholder="Type a message"
  contentEditable
/>


, — , . input onChange. div onInput, , . addEventListener , . :


class Input extends Component {
    setRef = (ref) => {
        this.ref = ref;
    };
    saveInputValue = () => {
        const text = this.ref.innerText;
        this.props.onChange(text);
    };
    componentDidMount() {
        this.ref.addEventListener('input', this.saveInputValue);
    }
    componentWillUnmount() {
        this.ref.removeEventListener('input', this.saveInputValue);
    }
    render() {
        return (
            <div
                className={styles.input}
                ref={this.setRef}
                placeholder="Type a message"
                contentEditable
            />
        );
    }
}

input ref innerText . div uncontrolled, .. , .



— . , Enter. Enter . . input keydown. :


onKeyDown = (event) => {
  if (event.keyCode === ENTER_KEY_CODE && event.shiftKey === false) {
    event.stopPropagation();
    event.preventDefault();

    this.props.submit();
  }
};
componentDidMount() {
  this.ref.addEventListener('keydown', this.onKeyDown);
  ...
}

event.preventDefault() Enter, event.stopPropogation() . , Shift+Enter .


-


— - . - . paste.


handlePaste = (event) => {
  event.preventDefault();

  const text = event.clipboardData.getData('text/plain');
  document.execCommand('insertText', false, text);
};
componentDidMount() {
  ...
  this.ref.addEventListener('paste', this.handlePaste);
}

event.preventDefault() , ( event.clipboardData === DataTransfer). , — . document.execCommand('insertText', false, text). , .. , .


. . fileList, — , , :


handlePaste = (event) => {
  event.preventDefault();

  if (event.clipboardData.files.length > 0) {
    // ...
  }

  const text = event.clipboardData.getData('text/plain');
  document.execCommand('insertText', false, text);
};

, .. , . .


Emoji


emoji . unicode emoji emoji . Emoji — , — execComand(‘insertText’). , , . ! , Emoji, , .




, , this.ref.focus() , :


insertEmoji = (emoji) => {
  if (this.ref) {
    this.ref.focus();
  }
  document.execCommand('insertText', false, emoji);
}

, : , Emoji, — . , - . , Emoji , , . .


, , .



API — Selection (MDN).


A Selection object represents the range of text selected by the user or the current position of the caret. To obtain a Selection object for examination or manipulation, call window.getSelection().

window.getSelection() , , , . , , — . API . , . :




gem4me, "m" "e". selection selection.anchorNode ( ) selection.anchorOffset (5). , selection.anchorNode , , , . , , — . :


updateCaretPosition = () => {
  const { node, cursorPosition } = this.state;

  const selection = window.getSelection();
  const newNode = selection.anchorNode;
  const newCursorPosition = selection.anchorOffset;

  if ( node === newNode && cursorPosition === newCursorPosition) {
    return;
  }

  this.setState({ node, cursorPosition });
}

, . , :


onInput, , . paste , .


onInput. , — . — , keyup ( keydown — , , ). , 2 ( , input keyup). state, . , .. , =).


. , ? click .


. -!


Emoji


, , Emoji. API Range, Selection.


The Range interface represents a fragment of a document that can contain nodes and parts of text nodes

, . :


const { node, cursorPosition } = this.state;

const range = document.createRange();
range.setStart(node, cursorPosition);
range.setEnd(node, cursorPosition); 


 , . — . :


const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);

selection, , . this.ref.focus() ! , , :


document.execCommand('insertText', false, emoji);

:


customFocus = () => {
  const { node, cursorPosition } = this.state;

  const range = document.createRange();
  range.setStart(node, cursorPosition);
  range.setEnd(node, cursorPosition);

  const selection = window.getSelection();
  selection.removeAllRanges();
  selection.addRange(range);
}

insertEmoji = (emoji) => {
  this.customFocus();
  document.execCommand('insertText', false, emoji);
};


, , . :


, , ( ). — , , . , , . Esc ( ).

, . keydown. , . :


if (
  event.keyCode === UP_ARROW_KEY_CODE &&
  this.props.isTextEmpty &&
  this.props.mode === INPUT_CONTEXT_TYPES.STANDARD
) {
}

, , . :


if (
  event.keyCode === UP_ARROW_KEY_CODE &&
  this.props.isTextEmpty &&
  this.props.mode === INPUT_CONTEXT_TYPES.STANDARD
) {
  event.preventDefault();
  event.stopPropagation();

  this.props.setMode('edit');
  document.execCommand('insertText', false, this.props.lastMessage); 
}

document.execCommand('insertText') , .


Esc . keydown . this.ref.textContent = "".



, , . — , .. , . , "@". updateCaretPosition:


updateAndProcessCaretPosition = () => {
  const { node, cursorPosition } = this.state;

  const selection = window.getSelection();
  const newNode = selection.anchorNode;
  const newCursorPosition = selection.anchorOffset;

  if (node === newNode && cursorPosition === newCursorPosition) {
    return;
  }

  if (this.props.isAvailableMention) {
    this.props.parseMention(node.textContent, cursorPosition);
  };

  this.setState({ node, cursorPosition });
}

isAvailableMention , . , true. :


parseMention = (text, cursorPosition) => {
  if (text && cursorPosition && text.includes('@')) {
    const lastWord = text
      .substring(0, cursorPosition)
      .split(' ')
      .pop();

    if (lastWord[0] === '@') {
      this.setFilter(lastWord.substring(1, cursorPosition));
      return;
    }
  }
  this.clearFilter();
};

, "@" . — , , "@", . , .


insertMention = (insertRestPieceOfMention) => {
  this.customFocus();
  document.execCommand('insertText', false, insertRestPieceOfMention + ' ');
};

, document.execCommand('insertText') : , , .



, , , , , 20+ , , . , - . — , , (, , , , , - ), , , , . — , .


, Gem4me


All Articles