@brandup/ui-messageeditor
v1.0.55
Published
Message editor styled as a chat bubble.
Maintainers
Readme
@brandup/ui-messageeditor
Поле ввода сообщения, оформленное как плашка в чате мессенджера: скруглённый пузырь, растущий по содержимому.
Устроен по тому же принципу, что и @brandup/ui-textbox: исходный input/textarea остаётся носителем значения и участвует в форме (валидация, submit, FormData), а ввод ведёт @brandup/ui-richeditor в соседнем редактируемом элементе.
Установка
npm i @brandup/ui-messageeditorИспользование
import MessageEditor from "@brandup/ui-messageeditor";
const elem = document.querySelector("textarea") as HTMLTextAreaElement;
const editor = new MessageEditor(elem);
editor.onChange(({ value }) => console.log(value));Конструктор вставляет плашку на место поля и переносит само поле внутрь — оно скрыто, но остаётся в форме и хранит значение.
Поле может объявить автофокус — нативным autofocus либо data-autofocus: плашка получает фокус сразу после сборки, а каретка встаёт в конец текста. Когда автофокус отменяется — см. @brandup/ui-input.
Свойства
| Свойство | Тип | Описание |
| --- | --- | --- |
| placeholder | string \| null | Текст-заглушка; берётся из атрибута placeholder поля |
| personalization | boolean | Включена ли персонализация; по умолчанию нет |
| variables | MessageVariable[] | Переменные персонализации; из опций либо из атрибута data-variables |
| variablesEmpty | string \| null | Текст в окне персонализации при пустом списке; из опций либо из атрибута data-variables-empty |
| variablesSetup | string \| (() => void \| boolean) | Настройка полей: адрес или действие для ссылки в окне персонализации; адрес — и из атрибута data-variables-setup |
| variablesSetupText | string | Подпись ссылки на настройку; из опций либо из атрибута data-variables-setup-text (по умолчанию «Настроить поля») |
| blocks | BlockType[] | Типы блоков сообщения: quote, code; из опций либо из атрибута data-blocks (по умолчанию все) |
| tools | FormatTool[] | Инструменты форматирования; из опций либо из атрибута data-tools (по умолчанию все) |
| keepFocus | boolean | Держать ли фокус, пока открыт попап смайликов (по умолчанию да, а на сенсорном устройстве нет) |
| source | boolean | Показ выхода: переключатель над плашкой и панель с разметкой значения; из опций либо из атрибута data-source (по умолчанию выключен) |
| variableLength | number | Сколько символов отводится переменной при подсчёте messageLength; из опций либо из атрибута data-variable-length (по умолчанию 30) |
Многострочность включена всегда: сообщение — это абзацы. Enter даёт один перенос (\n), два нажатия — пустую строку (\n\n), как в мессенджерах; форму он не отправляет.
Форматирование тоже включено всегда: панель с объявленными инструментами (по умолчанию — все) и кнопкой очистки формата всплывает над плашкой при фокусе. Кнопка очистки гаснет, когда снимать нечего. Вставка смайлика вынесена из панели в собственную кнопку внутри плашки — она доступна сразу, не дожидаясь фокуса в поле. Первой группой в попапе стоят недавно выбранные символы; помнит их @brandup/ui-richeditor — в localStorage, общем для всех редакторов источника. В disabled не строится ни панель, ни кнопка.
Окна персонализации и рандомизации забирают фокус у поля: правка идёт в них, а мигающая под окном каретка только сбивает с толку. Каретка при этом не теряется — по закрытию окна фокус возвращается вместе с ней. Попап смайликов фокус оставляет полю (видно, куда встанет символ), но на сенсорном устройстве отдаёт и он: там фокус держит на экране клавиатуру, а она закрывает собой саму панель. Поведение переключается опцией keepFocus.
Панель и попап смайликов вместе не показываются: пока открыт попап, панель убрана — это два всплывающих слоя над одним полем. Закрылся попап — панель возвращается, если поле осталось в фокусе.
Клик по плашке мимо текста — тоже клик по полю: фокус уходит в текст, каретка встаёт на прежнее место, а если её ещё не было — в конец. Кнопки внутри плашки (смайлик, панель форматирования) при этом работают сами.
Состояния disabled, readonly и required берутся у поля-носителя, как у остальных контролов кита. Разметку значения плашка показывает и в них — переключать её там нечем, но показать читателю жирный жирным она обязана; звёздочки вместо него означали бы не то, что дойдёт до получателя.
Набор разметки
«Мессенджер» — это не один канал, и понимают они разное: подчёркивания и спойлера нет в WhatsApp, цитаты нет в VK, разметки нет в SMS вовсе. Кнопка, разметку которой канал не покажет, хуже отсутствующей — размеченный ею текст уйдёт получателю либо голым, либо сырыми маркерами. Поэтому и инструменты, и типы блоков объявляются набором: опцией или атрибутом поля-носителя (значения через пробел, пустое значение — пустой набор). По умолчанию доступно всё.
<textarea data-tools="bold italic strike" data-blocks="quote"></textarea>
<textarea data-tools="" data-blocks=""></textarea>| | Опция | Атрибут | Значения | Пустой набор |
| --- | --- | --- | --- | --- |
| Инструменты | tools | data-tools | bold, italic, strike, underline, spoiler, code | Текст без разметки |
| Типы блоков | blocks | data-blocks | quote, code | Только обычный текст |
Порядок значений в атрибуте не важен: кнопки идут в каноническом порядке набора, незнакомые значения отбрасываются. Переданный в опциях набор имеет приоритет над атрибутом — приложение знает канал точнее, чем разметка от сервера.
Отдельного набора для разбора значения нет: ограничение действует и на него. Разметка снятого инструмента остаётся в тексте как есть — на экране сырыми маркерами, в значении ровно такой, какой была, — и уезжает обратно без потерь. Так же её увидит и получатель в канале, который её не знает. Кнопка очистки формата исчезает вместе с последним инструментом: снимать становится нечего. Отмена, повтор и кнопки рандомизации с переменными от набора не зависят — это не разметка, и канала они не касаются.
Тем же набором разбирается и вставка. Значение хранится маркдауном, поэтому маркеры во вставляемом простом тексте значат то же, что в значении, и разбираются так же: **раз** из буфера становится жирным, а маркер снятого инструмента остаётся текстом. Это касается и текстовых файлов (.md, .txt), брошенных в плашку, — файл вставляется содержимым через тот же разбор (см. @brandup/ui-richeditor).
Моноширинный и блок кода делит одна кнопка — как в мессенджерах: выделена одна строка или её часть, значит моноширинный, а несколько строк (или ничего не выделено) — блок из этих строк, остальной текст остаётся как был. Внутри кода остальные инструменты выключены: значение берёт оттуда голый текст. Внутри цитаты и блока кода Enter заканчивает блок и возвращает к обычному тексту, а строку внутри блока переносит Shift+Enter. Выйти из блока можно и Backspace в его начале, и той же кнопкой панели. Подробности разбора и хранения — в @brandup/ui-richeditor.
Показ выхода
Значение хранится не HTML, а разметкой мессенджеров (markdown), и иногда её нужно видеть целиком — сверить перед отправкой, скопировать в шаблон, показать поддержке, что именно уйдёт в канал. Для этого над плашкой встаёт переключатель на два режима, а рядом с ней — панель, в которую рендерится выход: Текст показывает сообщение, Markdown — его разметку.
По умолчанию ничего этого нет: пишущему сообщение сырая разметка не нужна, а показанная без спроса требует объяснений. Включается опцией source или атрибутом data-source поля-носителя:
<textarea data-source></textarea>new MessageEditor(elem, { source: true });Название второй кнопки — это формат хранения (Markdown), в него и переключают. Панель показывает ровно то, что лежит в поле-носителе и уедет в форму, поэтому отложенное при печати изменение доставляется перед показом. Значение от переключения не меняется: панель только показывает, править по-прежнему можно в плашке — текст в ней выделяется и копируется, но не редактируется. Пока панель открыта, показанное следует за значением: его меняет и хост через setValue, и окно правки, открытое до переключения.
Два вида одного значения разом на экране не держатся: панель показывается вместо плашки, иначе непонятно, какой из них правится. Фокус при переключении уходит из плашки — панель форматирования иначе висела бы над скрытым текстом. Обратно в неё он встаёт вместе с прежней кареткой, когда режим переключают кнопкой: нажали в поле — значит, продолжают писать. В disabled и readonly переключатель работает (смотреть значение — не значит изменять его), но фокус там не возвращается: в readonly фокус выделяет всё сообщение целиком, и смена вида делала бы то же самое.
Переключение из кода фокус не трогает: setValue и toggleSource зовут когда угодно, а уводить фокус со страницы в поле — и поднимать на сенсорном устройстве экранную клавиатуру — оно не должно. Нужно поведение кнопки — просят его вторым аргументом.
editor.toggleSource(true); // показать разметку
editor.toggleSource(); // обратно к сообщению, фокус остаётся где был
editor.toggleSource(false, true); // обратно к сообщению вместе с фокусом и кареткойПеременные и рандомизация
Две доменные кнопки в панели, которых нет у обычного редактора. Обе открывают модальное окно и вставляют результат в каретку; выделение при этом не теряется — панель не забирает фокус.
Персонализация {ИМЯ} — выбор переменной из списка. По умолчанию выключена: без неё нет ни кнопки, ни подсветки, и {ИМЯ} остаётся обычным текстом. Включается опцией personalization, атрибутом data-personalization или самим объявлением списка — объявили, значит нужна. В значение уходит ключ ({ИМЯ}), а в тексте и в списке показывается название, если оно задано. Подставляет переменную приложение при отправке, компонент только вставляет разметку.
Рандомизация [раз|два] (спинтакс) — варианты набираются вручную, вариант выбирается при отправке. Выделенный текст попадает в окно первым вариантом — уже без символов самой конструкции ([, ], |) и в одну строку: внутри варианта они развалили бы спинтакс, поэтому их не вводят и не вставляют, и текст со стороны проходит ту же чистку. Без выделения первым вариантом становится слово под кареткой — рандомизируют чаще всего то слово, на котором стоят, — и собранный спинтакс встаёт на его место, а не разрывает слово; каретка вне слова (за точкой) открывает окно пустым. Готовый спинтакс, открытый на правку кликом, разбирается на варианты как есть.
Обе конструкции подсвечиваются прямо в тексте и атомарны: в поле их не отредактировать, клик по конструкции открывает то же окно для правки. Backspace и Delete у конструкции стирают её целиком — стереть в ней символ нельзя, а нативное удаление рядом с нередактируемым элементом браузеры делают по-разному; за конструкцией в конце строки к тому же стоит невидимая опора каретки, и нажатие уходило бы на неё. Удаление словами и строками (Ctrl, Alt, Cmd) остаётся браузеру: это правка текста вокруг, а не самой конструкции. При наведении подсветка темнеет, а курсор — стрелка: конструкция кликабельна, и это видно до клика. В readonly и disabled клик не работает — там нет ни отклика, ни курсора. Форматирование обходится с ними как с одним символом — выделили текст с переменной и сделали жирным, получится **раз {ИМЯ} два**, а не три отдельных куска. Подсветка — это обычные span, которые сериализация отбрасывает, поэтому в значение она не попадает: там остаётся ровно {ИМЯ} и [раз|два].
Список переменных задаётся опцией (она же включает персонализацию):
new MessageEditor(elem, {
variables: [{ key: "ИМЯ", name: "Имя подписчика" }, { key: "ГОРОД" }],
});либо атрибутом data-variables поля-носителя — когда разметку отдаёт сервер и передавать список неоткуда:
<textarea data-variables="ИМЯ, ГОРОД"></textarea>
<textarea data-variables='[{"key":"ИМЯ","name":"Имя подписчика"},"ГОРОД"]'></textarea>Простая форма — ключи через запятую (только запятая: ключ может содержать пробелы). Форма JSON нужна, когда у переменных есть названия; элементом может быть и строка. Переданный в опциях список имеет приоритет над атрибутом, негодный JSON даёт пустой список и сообщение в консоли.
Переменную, набранную или вставленную названием, поле само приводит к ключу: {Имя подписчика} становится {ИМЯ}. И в поле, и в списке она показана названием — набирают её с экрана, тем, что видно, — а подставить название нечем: в сообщение уходит ключ, и получателю такая переменная ушла бы скобками наружу. Подмена происходит сразу, пока пишущий видит результат, и на экране от неё ничего не меняется: там снова название. Регистр и лишние пробелы в написанном не важны (название — человеческий текст, а не код), а объявленный ключ важнее чужого названия: написанное, совпавшее с ключом, остаётся как есть.
Переменная с ключом, которого нет в объявленном списке, помечается в тексте отдельно — заливкой и волнистым подчёркиванием (span.variable.unknown), с подсказкой в title. Подставить её нечем: на отправке она уйдёт получателю скобками наружу, а выглядит при этом ровно как рабочая, и опечатка в ключе замечается уже по отправленному сообщению.
Это ошибка значения, а не оформления, поэтому она же останавливает отправку формы: контрол объявляет её полю-носителю через setCustomValidity, дальше решает браузер — как и с нативным required. Пока список объявлен, подпись невалидности на поле принадлежит контролу: свою приложению придётся держать где-то ещё. По destroy она снимается — иначе поле осталось бы в форме навсегда невалидным, а объяснить это было бы нечем. Список ключей отдаёт свойство unknownVariables:
editor.onChange(() => {
const unknown = editor.unknownVariables; // ["ИМЯЯ"]
hint.textContent = unknown.length ? `Нет таких переменных: ${unknown.join(", ")}` : "";
});Пустой список — не ошибка: кнопка остаётся на месте, а окно объясняет, почему выбирать нечего. Текст задаётся опцией variablesEmpty или атрибутом data-variables-empty, по умолчанию — «Переменные не заданы.» (VARIABLES_EMPTY_TEXT). Причину знает приложение: переменные могут появиться после выбора аудитории, а могут быть не предусмотрены вовсе. Пока список пуст, ничего не проверяется и не помечается: набор ещё не известен, и чужой в нём не отличить от своего. Поля контрол тогда не касается вовсе — выставленную приложением подпись setCustomValidity в этом случае не стирает.
<textarea data-variables-empty="Переменные появятся после выбора аудитории."></textarea>Настройка полей
Список переменных где-то ведётся, и из окна персонализации туда должно быть видно дорогу — особенно когда список пуст или нужного поля в нём не нашлось. Ссылка на настройку встаёт последней строкой окна, в обоих состояниях списка одинаково: при пустом это главный выход, при заполненном — запасной. Без объявленной настройки строки нет вовсе.
Где настройка живёт, знает приложение: строка — адрес перехода, и рисуется настоящая <a href> (работают средняя кнопка и «копировать адрес»); функция — действие хоста, SPA-переход или собственное окно, и рисуется кнопка в виде ссылки. Объявленная настройка — тоже согласие на персонализацию, как и объявленный список переменных.
new MessageEditor(elem, { variablesSetup: "/settings/fields" });
new MessageEditor(elem, { variablesSetup: () => app.nav("/settings/fields") });<textarea data-variables-setup="/settings/fields" data-variables-setup-text="Управление полями"></textarea>По нажатию окно закрывается молча — без возврата каретки в поле: фокус уходит на другой экран, и мигающая каретка в оставленном поле только сбивала бы с толку. Функция может попросить окно не трогать, вернув false, — например, когда открывает собственный слой поверх; действие при этом выполняется до закрытия. Подпись задаётся опцией variablesSetupText или атрибутом, по умолчанию — «Настроить поля» (VARIABLES_SETUP_TEXT).
Длина сообщения
У каналов есть лимиты на длину сообщения, а у сообщения с конструкциями нет одной длины: спинтакс уйдёт одним из вариантов, переменная развернётся в подставленное значение. Свойство messageLength отдаёт оценку — по видимому тексту, без маркеров форматирования, обрезанную по краям, как значение из getValue():
- спинтакс
[раз|два]считается самым длинным вариантом: в отправку уйдёт любой из них, и лимит обязан выдержать каждый; - переменная
{ИМЯ}считается условной длиной подставляемого значения — по умолчанию 30 символов. Сколько на самом деле, знает приложение: задаётся опциейvariableLengthили атрибутомdata-variable-length. Без персонализации{ИМЯ}— обычный текст и считается по буквам.
Вложенных конструкций нет: переменная внутри варианта спинтакса — часть его текста и считается по буквам, как и в проверке объявленных переменных.
<textarea data-variable-length="20"></textarea>Своего вывода у длины пока нет: свой лимит приложение проверяет само, читая messageLength на onChange. Проверку формы длина не держит — оценка с конструкциями останавливала бы отправку по догадке. Считает ту же длину и отдельная функция messageLength(root, options), если содержимое пришло не из компонента.
API
| Метод | Описание |
| --- | --- |
| getValue(): string | Текущее значение (обрезанное по краям) |
| setValue(value): void | Заменить значение |
| hasValue(): boolean | Есть ли непустое значение |
| validate(): boolean | Проверка по атрибутам поля-носителя, проставляет класс invalid |
| unknownVariables | Ключи переменных из текста, которых нет в объявленном списке (в порядке появления) |
| messageLength | Оценка длины сообщения: спинтакс — самым длинным вариантом, переменная — условной длиной (variableLength); считается на месте, по текущему содержимому |
| sourceMode | Показан ли выход вместо плашки |
| toggleSource(show?, focus?) | Переключить режим показа; без аргумента — на противоположный. Фокус по умолчанию не трогается, focus: true возвращает его в плашку вместе с кареткой (кроме disabled и readonly). Без включённого показа выхода не делает ничего |
| onChange(handler) | Подписка на событие messageeditor-change |
| editor | Доступ к встроенному RichEditor — выделение, вставка текста |
| destroy(): void | Возвращает поле на место и освобождает ресурсы; открытое окно правки закрывается, своя подпись невалидности снимается |
Валидация штатная: ограничения объявлены на поле-носителе, решение принимает браузер — он же блокирует отправку формы. Контрол только отражает результат классом invalid. Своё ограничение у него одно — неизвестная переменная, и объявляется оно тем же способом, что и у остальных контролов кита, через setCustomValidity.
Пакет экспортирует и внутренние части, если они нужны отдельно: VariablesModal, RandomizerModal, parseVariables, buildVariable, buildSpintax, parseSpintax, messageLength, DEFAULT_VARIABLE_LENGTH, VARIABLES_EMPTY_TEXT, VARIABLES_SETUP_TEXT, типы VariablesSetup и LengthOptions.
Оформление
Разметка: корневой .ui-messageeditor → .bubble (плашка) → редактируемый элемент .ui-richeditor и коробка кнопки .messageeditor-emoji-holder с кнопкой .messageeditor-emoji справа от него. Подсвеченные конструкции в тексте — span.variable и span.spintax; необъявленная переменная — span.variable.unknown.
Внутри конструкции символы самой разметки — скобки и разделитель вариантов — лежат в собственных обёртках span.mark (по умолчанию у каждой отступ в пиксель с обеих сторон), а между ними содержимое: варианты спинтакса обычным текстом, ключ переменной — тоже, либо в span.key, когда у неё есть название. Название показывается оформлением пустой обёртки span.label (content: attr(data-label)): узел с текстом попал бы в текст поля, а из него собирается значение сообщения. Текст конструкции от этого разбиения не меняется — сериализация вложенные обёртки отбрасывает, оставляя их текст.
При включённом показе выхода перед плашкой встаёт переключатель .messageeditor-modes с кнопками .messageeditor-mode (выбранная — .active), а после неё — панель .messageeditor-source. Видимость плашки и панели переключает класс source на корневом элементе, и вешает его сам компонент: сам режим он держит в себе, а из класса не читает — собственные классы поля-носителя переезжают на корневой элемент контрола, и class="source" в разметке поля включал бы режим, которого нет. По той же причине плашку прячет только класс режима при собранной панели (.source:has(> .messageeditor-source)).
Прокручивается текст, а не плашка: кнопка остаётся на месте, когда сообщение перерастает --messageeditor-maxheight.
Панель форматирования монтируется в корневой элемент и позиционируется от него, поэтому он position: relative. Панель смайликов — в коробку кнопки: она раскрывается над кнопкой, а не над всем редактором, и для этого коробка позиционирована и повторяет её габариты (отступы держит коробка, иначе панель встала бы со сдвигом).
Заливка по умолчанию своя, а не от полей ввода: белая плашка на белой странице держалась бы только на тени и почти не читалась бы.
Настраивается CSS-переменными:
| Переменная | По умолчанию | Что задаёт |
| --- | --- | --- |
| --messageeditor-fill | #e7f3ff | Заливка плашки |
| --messageeditor-color | var(--input-color, #222) | Цвет текста |
| --messageeditor-radius | 18px | Скругление плашки |
| --messageeditor-minheight | var(--input-line-height, 1.2rem) | Высота содержимого пустой плашки (иначе она схлопывается в отступы) |
| --messageeditor-maxheight | 220px | Высота, после которой появляется прокрутка |
| --messageeditor-padding-tb | считается от --input-height | Отступы плашки сверху и снизу: такие, что пустая плашка ровно в высоту полей ввода кита, а строка стоит по центру. По горизонтали берётся --input-padding-lr, общий для полей ввода |
| --messageeditor-gap | 8px | Расстояние от текста до кнопки смайлика |
| --messageeditor-button-size | 32px | Размер кнопки смайлика |
| --messageeditor-button-icon | 20px | Размер иконки на кнопке |
| --messageeditor-button-color | var(--placeholder-color, #999) | Цвет иконки (в наведении и при открытой панели — акцент) |
| --messageeditor-shadow | 0 1px 2px rgba(0,0,0,.16) | Тень плашки |
| --messageeditor-border | var(--messageeditor-fill) | Рамка плашки — в цвет заливки, поэтому в покое не видна |
| --messageeditor-border-width | 1px | Толщина рамки |
| --messageeditor-border-focus | заливка, затемнённая на 12% | Рамка в фокусе |
| --messageeditor-accent | var(--focus--input-border-color, #4a9eff) | Цвет иконки смайлика при наведении и открытой панели |
| --messageeditor-spintax-fill | rgba(255,193,7,.28) | Подсветка спинтакса [раз\|два] в тексте |
| --messageeditor-variable-fill | rgba(76,175,80,.24) | Подсветка переменной {ИМЯ} в тексте |
| --messageeditor-variable-unknown-fill | rgba(244,67,54,.24) | Подсветка переменной, которой нет в объявленном списке |
| --messageeditor-variable-unknown-mark | rgba(244,67,54,.8) | Волнистое подчёркивание под ней — отличие не только цветом |
| --hover--messageeditor-spintax-fill | заливка, затемнённая на 12% | Подсветка спинтакса при наведении |
| --hover--messageeditor-variable-fill | заливка, затемнённая на 12% | Подсветка переменной при наведении |
| --hover--messageeditor-variable-unknown-fill | заливка, затемнённая на 12% | Подсветка неизвестной переменной при наведении |
| --messageeditor-source-fill | var(--input-fill, #fff) | Заливка панели выхода — нейтральная, как у полей ввода: это не сообщение, а его разметка |
| --messageeditor-source-border | var(--input-border-color, #dcdfe4) | Рамка панели выхода |
| --messageeditor-source-color | var(--messageeditor-color) | Цвет текста разметки |
| --messageeditor-source-font | системный моноширинный | Шрифт разметки |
| --messageeditor-modes-gap | 6px | Расстояние от переключателя до плашки |
| --messageeditor-quote-fill | заливка плашки, затемнённая на 6% | Подложка цитаты в сообщении — в тон плашки, а не серая по умолчанию редактора |
Классы состояний вешаются на корневой элемент: focused, invalid, disabled, readonly, source.
