Comment nous avons développé le champ de saisie de nouveaux messages dans notre messager (Gem4me)

Bonjour à tous!



Je m'appelle Alexander Baltsevich, je travaille sur la position de leadership de l'équipe Web du projet Gem4me. Le projet est un messager innovant pour tout le monde et tout le monde (jusqu'à présent dans mes fantasmes, mais nous nous efforçons pour cela ;-))


En bref sur la pile de versions Web: ReactJS (qui en douterait) + mobX (personnellement, je ne suis pas du tout enthousiaste, mais nous ne prévoyons pas de migrer n'importe où; si vous êtes intéressé par les détails, qu'est-ce qui ne vous convient pas exactement, écrivez des commentaires - je ferai peut-être un article séparé à ce sujet) + livre de contes + wdio (test de capture d'écran).


Le messager est avant tout un programme d'échange de messages courts (et alors seulement tous ces appels, autocollants et autres conférences). Les messages sont des fonctionnalités de base qui devraient bien fonctionner. Non, pas comme ça: ça devrait fonctionner parfaitement. Et seul celui qui a rencontré au moins une fois le développement de cette fonctionnalité connaît toute la douleur à surmonter pour que tout soit beau. Nous avons tous vécu cela avec l'équipe. Et nous avons décidé de partager.




Exigences


Quelles fonctionnalités un champ de saisie doit-il fournir?


  • Il est banal d'imprimer du texte, avec la possibilité d'écrire sur plusieurs lignes (en appuyant sur Maj + Entrée). Les utilisateurs avancés sont également habitués à utiliser des caractères de démarque pour formater rapidement les messages.



  • Collez le texte du tampon. Souvent, lors de la copie de texte, l'arrière-plan sous ce texte est également copié (voir l'exemple ci-dessous) et formaté. Mais un texte propre et net et rien de plus ne devrait entrer dans le champ de saisie.



  • Insérez des images du presse-papiers. Il me semble que personne ne peut vivre sans cette fonctionnalité.
  • Smileys =) sont depuis longtemps devenus une sorte de partie intégrante de la culture de la communication. Par conséquent, vous ne pouvez pas oublier les sourires. Au début du texte, au milieu, dix morceaux d'affilée ou un à la fois à travers trois mots insérés à partir de la bibliothèque interne ou copiés à partir d'une autre ressource - dans toutes les variations et variations, doivent toujours être dessinés en cinq avec un plus.
  • , , ( ). — , , . , , . 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