Как мы разрабатывали поле ввода новых сообщений в нашем мессенджере (Gem4me)
- четверг, 30 апреля 2020 г. в 00:28:59
Всем привет!
Меня зовут Александр Бальцевич, я работаю на лидерской позиции Web-команды проекта Gem4me. Проект представляет из себя инновационный месенджер для всех и каждого (пока в моих фантазиях, но мы стремимся к этому ;-) )
Коротко о стэке веб-версии: ReactJS (кто бы сомневался) + mobX (лично я вообще не в восторге, но мигрировать никуда не планируем; если интересны детали, в чем именно не устраивает, пишите комментарии — возможно сделаю про это отдельную статью) + storybook + wdio (скриншотное тестирование).
Месенджер — это в первую очередь программа для обмена короткими сообщениями (а уж потом все эти звонки, стикеры и прочие конференции). Сообщения — базовый функционал, который должен работать хорошо. Нет, не так: он должен работать идеально. И только тот, кто хоть раз сталкивался с разработкой этого функционала, знает всю боль, которую приходится преодолевать, чтобы все выглядело красиво. Мы все это прошли с командой. И решили поделиться.
Какие возможности должно предоставлять поле ввода?
Функционал "упоминание пользователя" ("mention") слишком здоровый, его вынесу в отдельный блок. Нужно реализовать следующее:
Фух, вроде все.
Также есть требования, которые мы еще не реализовали, но для тех, кому любопытно — вот парочка идей:
Есть, конечно, и другая полезная функциональность — для отправки стикеров, реплая на сообщение и т.д., но она не влияет особо на поведение самого поля ввода, скорее на область вокруг него, поэтому все это отбросим.
Начинаем мы с выбора, какой 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 вашей системы. Есть общеизвестная таблица 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. Для вставки мы опять же используем браузерное 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