Cómo desarrollamos el campo para ingresar nuevos mensajes en nuestro messenger (Gem4me)

¡Hola a todos!



Mi nombre es Alexander Baltsevich, trabajo en la posición de liderazgo del equipo web del proyecto Gem4me. El proyecto es un mensajero innovador para todos y para todos (hasta ahora en mis fantasías, pero nos esforzamos por esto ;-))


Brevemente sobre la pila de versiones web: ReactJS (quién lo dudaría) + mobX (personalmente, no estoy entusiasmado en absoluto, pero no planeamos migrar a ningún lado; si está interesado en los detalles, qué es exactamente lo que no le conviene, escriba comentarios; tal vez haga un artículo separado al respecto) + storybook + wdio (prueba de captura de pantalla).


El messenger es principalmente un programa para intercambiar mensajes cortos (y solo entonces todas estas llamadas, calcomanías y otras conferencias). Los mensajes son funciones básicas que deberían funcionar bien. No, no así: debería funcionar perfectamente. Y solo el que al menos una vez encontró el desarrollo de esta funcionalidad conoce todo el dolor que debe superarse para que todo se vea hermoso. Todos pasamos por esto con el equipo. Y decidimos compartir.




Requisitos


¿Qué características debe proporcionar un campo de entrada?


  • Es sencillo imprimir texto, con la capacidad de escribir en varias líneas (presionando Mayús + Entrar). Los usuarios avanzados también están acostumbrados a usar caracteres de descuento para formatear mensajes rápidamente.



  • Pega el texto del búfer. A menudo, al copiar texto, el fondo debajo de este texto también se copia (vea el ejemplo a continuación) y se formatea. Pero el texto limpio y ordenado y nada más debe entrar en el campo de entrada.



  • Insertar imágenes desde el portapapeles. Me parece que nadie puede vivir sin esta característica.
  • Los emoticones =) se han convertido en una especie de parte de la cultura de la comunicación. Por lo tanto, no puedes olvidarte de las sonrisas. Al comienzo del texto, en el medio, diez piezas seguidas o una a la vez a través de tres palabras insertadas de la biblioteca interna o copiadas de otro recurso, en cualquier variación y variación, siempre deben dibujarse en cinco con un signo más.
  • , , ( ). — , , . , , . 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