Всем привет!?


Меня зовут Александр Бальцевич, я работаю на лидерской позиции Web-команды проекта Gem4me. Проект представляет из себя инновационный месенджер для всех и каждого (пока в моих фантазиях, но мы стремимся к этому ;-) )


Коротко о стэке веб-версии: ReactJS (кто бы сомневался) + mobX (лично я вообще не в восторге, но мигрировать никуда не планируем; если интересны детали, в чем именно не устраивает, пишите комментарии — возможно сделаю про это отдельную статью) + storybook + wdio (скриншотное тестирование).


Месенджер — это в первую очередь программа для обмена короткими сообщениями (а уж потом все эти звонки, стикеры и прочие конференции). Сообщения — базовый функционал, который должен работать хорошо. Нет, не так: он должен работать идеально. И только тот, кто хоть раз сталкивался с разработкой этого функционала, знает всю боль, которую приходится преодолевать, чтобы все выглядело красиво. Мы все это прошли с командой. И решили поделиться.




Требования


Какие возможности должно предоставлять поле ввода?


  • Банально печатать текст, с возможностью писать в несколько строк (нажимая shift + Enter). Опытные пользователи также привыкли использовать markdown-символы для быстрого форматирования сообщения.



  • Вставка текста из буфера. Часто при копировании текста также копируется фон под этим текстом (пример смотрите ниже) и форматирование. Но в поле ввода должен попасть чистый аккуратный текст и ничего лишнего.



  • Вставка изображений из буфера. Мне кажется, без этой фичи уже никто не может жить.
  • Смайлы =) давно стали своеобразной частью культуры общения. Поэтому про смайлы забывать нельзя. В начале текста, в середине, по десять штук подряд или по одному через три слова, вставленные из внутренней библиотеки или скопированные с другого ресурса — в любых вариантах и вариациях, всегда должны быть отрисованы на пять с плюсом.
  • В случае, если поле ввода пустое, при нажатии стрелки вверх должен включиться режим редактирования для последнего написанного сообщения (пример смотрите ниже). Опустим детали — что кроме текста в поле ввода должен появиться некий блок над ним, дающий понять, что пользователь сейчас находится в режиме редактора. Для поля непосредственно нужно, чтобы у нас появился текст последнего сообщения, а курсор стоял в конце этого текста. При нажатии 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