@brandup/ui-richeditor
v1.0.55
Published
Rich text editor over a contenteditable element (bold, italic, strike, underline).
Downloads
3,018
Maintainers
Readme
@brandup/ui-richeditor
Редактор текста на базе contenteditable. Получает в конструкторе элемент, делает его редактируемым и берёт на себя всю работу с вводом: форматирование (жирный, курсив, зачёркивание, подчёркивание), панель инструментов, режим набора, нормализацию пробелов и сериализацию значения в HTML или Markdown.
Реализован на чистом Selection/Range API — без устаревшего document.execCommand, поэтому разметка всегда семантическая и предсказуема между браузерами.
Установка
npm i @brandup/ui-richeditorИспользование
import RichEditor from "@brandup/ui-richeditor";
const elem = document.getElementById("editor") as HTMLElement;
const editor = new RichEditor(elem, {
format: true,
tools: ["bold", "italic", "strike", "underline"],
storage: "html", // или "markdown"
placeholder: "Введите текст",
multiline: true,
});
editor.onChange(({ value }) => console.log(value));Конструктор оборачивает переданный элемент в div.ui-richeditor (к нему привязан UIElement) и делает элемент редактируемым (div.ui-richeditor-input).
Панель форматирования
Панель — общая для всех редакторов (div.ui-richeditor-toolbar). При фокусе редактора она перестраивается под его инструменты, позиционируется над ним и показывается; при потере фокуса — скрывается. Кнопки диспатчат форматирование напрямую активному редактору.
Внутри обёртка разделена надвое: сама .ui-richeditor-toolbar отвечает только за положение, а вид и содержимое держит коробка .toolbar-body внутри неё. Её размер меняется вместе с содержимым — на правку адреса ссылки, например, — а точка привязки от этого съезжать не должна; выпадающие слои вроде панели смайликов висят на обёртке и коробкой не обрезаются. Все кнопки панели несут общий класс .toolbar-button (плюс свой — .format-button, .block-button, .action-button, .host-button), по нему они и оформляются.
По умолчанию панель живёт в document.body (position: fixed) — это защищает её от обрезки overflow: hidden у родителей. Если задан toolbarContainer, панель монтируется в него и позиционируется относительно него (position: absolute, над контейнером) — например, TextBox передаёт свой контейнер .ui-textbox.
Панель не шире экрана (и не шире контейнера в режиме toolbarContainer), а правый край не уходит за границу: у поля справа она прижимается к краю экрана. Полный набор кнопок, не влезший по ширине — обычное дело на телефоне, — прокручивается внутри .toolbar-body по горизонтали: кнопки не переносятся и не прячутся, панель остаётся в одну строку. Полоса прокрутки — общая, от .ui-scrollable кита, только тоньше.
Опции (RichEditorOptions)
| Опция | Тип | Описание |
| --- | --- | --- |
| format | boolean | Включает форматирование и панель инструментов |
| tools | FormatTool[] | Состав инструментов (по умолчанию все); им же разбирается и сохраняется значение — разметка, которой в наборе нет, остаётся в тексте как есть |
| actions | EditorAction[] | Кнопки действий в панели: emoji, erase, undo, redo (по умолчанию нет) |
| storage | "html" \| "markdown" | Формат сериализации значения (по умолчанию html) |
| markers | Partial<FormatMarkers> | Переопределение markdown-маркеров по инструментам |
| placeholder | string \| null | Текст-заглушка |
| multiline | boolean | Многострочный режим |
| paragraph | "block" \| "break" | Что такое абзац: абзац, отделённый пустой строкой (по умолчанию), или строка, как в мессенджерах |
| blocks | BlockType[] | Типы блоков многострочного режима: quote, code (по умолчанию все); пустой список оставляет только paragraph |
| keepFocus | boolean | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
| readonly | boolean | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
| disabled | boolean | Выключенное поле: всё то же, что readonly, плюс снятый contenteditable — редактор не принимает ни фокус, ни выделение. Значение при этом не нормализуется — выключенное поле его не меняет |
| toolbarContainer | HTMLElement \| null | Контейнер для панели; по умолчанию document.body (position: fixed). Если задан — панель монтируется в него и позиционируется над ним (position: absolute). Контейнер должен быть position: relative |
| value | string | Начальное значение |
| filterChar | (char) => boolean | Хук: false — отклонить вводимый символ |
| filterPaste | (text) => string \| null | Хук: null — отклонить вставку; иначе очищенный текст |
| onReject | () => void | Хук: ввод отклонён (символ/вставка) |
| onEnter | () => void | Хук: Enter в однострочном режиме |
Хуки filterChar/filterPaste/onReject/onEnter позволяют хосту (например, @brandup/ui-textbox) накладывать собственные ограничения — фильтрацию по типу, submit формы, индикацию ошибки — не вмешиваясь в работу редактора.
API
| Член | Описание |
| --- | --- |
| editable | Редактируемый элемент |
| format, editorActions, formatStorage, formatMarkers, multiline | Параметры экземпляра |
| formatTypes | Объявленный набор инструментов — им разбирается и сохраняется значение |
| formatTools | Инструменты в панели: то же, но пусто в readonly — переключать разметку там нечем |
| getValue(): string | Сериализованное значение (по storage) — считается по DOM, всегда актуально |
| setValue(value: string): void | Установить значение (нормализует, генерирует change) |
| flushChange(): void | Доставить отложенное change немедленно (см. ниже) |
| getLength(): number | Длина текста (без учёта переводов строк) |
| focus(atEnd?): void | Установить фокус; каретку не двигает, а при atEnd ставит её в конец, если её ещё не было |
| releaseFocus(): void | Отпустить фокус, запомнив каретку — на время своего окна |
| applyFormat(tool): void | Переключить формат на выделении (слово целиком) |
| applyBlock(type): void | Переключить тип блоков под выделением; повторное применение возвращает обычный текст |
| applyCode(): void | Код по выделению: моноширинный для части строки, блок — для целых строк |
| isCodeActive(): boolean | Включён ли код в любом виде — подсветка объединённой кнопки |
| applyLink(url): void | Поставить ссылку, поменять её адрес или снять её (пустым адресом) |
| currentLink: string | Адрес ссылки под кареткой; пусто — каретка не в ссылке |
| caretSnapshot(): [number, number] \| null | Каретка в символах — вернуть её потом можно и без фокуса |
| restoreCaret(bounds): void | Вернуть каретку по снимку |
| currentBlock: BlockType | Тип блока под кареткой |
| blockTypes: BlockType[] | Доступные типы блоков |
| isToolActive(tool): boolean | Активен ли формат на текущем выделении |
| isToolEnabled(tool, codeActive?): boolean | Доступен ли инструмент сейчас (внутри кода остальные выключены). Второй аргумент — уже посчитанный признак «в коде»: панель считает его один раз на обновление и передаёт сюда, чтобы не обходить выделение на каждую кнопку |
| readonly, disabled | Состояния поля. readonly истинно и у выключенного поля: выключенное — это «только чтение плюс снятый contenteditable» |
| activeTools(): ReadonlySet<FormatTool> | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
| clearFormat(): void | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
| clearAllFormat(): void | Снять всё форматирование со всего содержимого |
| undo(): void, redo(): void | Отмена и повтор |
| canUndo, canRedo | Доступность отмены/повтора |
| applyAction(action): void | Выполнить действие панели (erase/undo/redo) |
| isActionEnabled(action): boolean | Доступно ли действие сейчас |
| insertText(text): void | Вставить текст в каретку (или вместо выделения) с учётом режима набора; без фокуса вставляет по снятой каретке |
| deleteNodes(nodes): void | Удалить узлы содержимого одной правкой: с историей, кареткой на их месте и одним изменением значения |
| openEmojiPicker(picker, initiator): boolean | Показать переданный попап смайликов у кнопки; false — этим нажатием он закрылся |
| selection: Selection \| null | Выделение, если оно внутри редактора (иначе null) — единая точка доступа для хоста |
| selectNode(node): void | Выделить узел внутри редактора: следующая вставка заменит его целиком |
| caretWord: string | Слово под кареткой; пусто при своём выделении, без каретки или когда каретка не в слове |
| selectCaretWord(): boolean | Выделить слово под кареткой — следующая вставка встанет на его место; false — выделять нечего |
| onChange(handler) | Подписка на событие richeditor-change |
| destroy(): void | Разворачивает элемент обратно и освобождает ресурсы |
Фокус на время своего слоя
Окно хоста (модальное) забирает фокус всегда: правка идёт в нём, а мигающая под ним каретка только сбивает с толку. Панель смайликов — наоборот, слой над полем: каретка на виду, и видно, куда встанет символ. На сенсорном устройстве фокус вместо этого поднимает экранную клавиатуру, которая саму панель и закрывает, — там его отпускают и для неё (keepFocus).
Каретка при этом не теряется: она снимается текстовыми смещениями, insertText() возвращает её сам (вставка из панели идёт без фокуса), а focus() — вместе с фокусом. Правку на это время придерживает вызывающий: снятие фокуса не конец ввода, и нормализация обрезала бы пробел у каретки.
Событие изменения
getValue() считает значение по DOM и точен всегда. А вот уведомление richeditor-change при печати доставляется с задержкой: сериализация — самая дорогая операция редактора (обход всего содержимого), а печать даёт input на каждый символ.
Это троттлинг, а не debounce: при непрерывном наборе событие приходит каждые ~150 мс, а не откладывается до паузы. Откладывается только печать — вставка, форматирование, отмена/повтор, setValue и Enter сообщаются сразу.
Отложенное доставляется немедленно:
- при потере фокуса и при
destroy()— редактором самостоятельно; - по вызову
flushChange()— его должен звать хост перед тем, как значение прочитают снаружи.
Хосты пакета (@brandup/ui-textbox, @brandup/ui-messageeditor) держат в поле формы копию значения и сбрасывают отложенное перед каждым чтением значения снаружи: отправка формы, validate(), getValue(). На submit синхронизация идёт в фазе перехвата на документе — то есть раньше любого обработчика самой формы и независимо от того, включена ли её валидация (novalidate). Так что в отправляемых данных значение всегда актуально; свой хост обязан делать то же самое (в @brandup/ui-input для этого есть __syncValue()).
Не покрыт единственный случай: new FormData(form) вне отправки формы, в пределах окна троттлинга после ввода. Список полей там собирается до того, как о нём можно узнать, а точечно заменить свою запись FormData не позволяет. Читайте в таком коде getValue() хоста — он синхронизирует сам.
Поведение форматирования
- Формат — переключатель (toggle): повторное применение снимает его.
- Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
- Слово — то, что стоит между пробелами, без небуквенных знаков по краям: каретка в слове перед точкой не отдаёт форматированию точку. Внутренние знаки — часть слова:
[email protected],по-русски,don'tберутся целиком. Слово не кончается на границе тега (Дарим <b>ск</b>идку— одно слово «скидку»), но не пересекает перенос строки и готовую конструкцию (contenteditable="false"). Каретка вне слова (сразу за точкой) не расширяется никуда — включается режим набора. Явное выделение только растёт: выделенные знаки из него не выпадают. - Режим набора: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
- Хоткеи
Ctrl/Cmd+B/I/UиCtrl/Cmd+K(ссылка — показывает в панели поле адреса). Зачёркивание — только кнопкой. - Отмена/повтор:
Ctrl/Cmd+Z— отмена,Ctrl+YилиCtrl/Cmd+Shift+Z— повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому доступна только при включённом форматировании (format: true). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше). - При потере фокуса и после
setValueпробелы нормализуются (схлопывание повторов + обрезка краёв строк). Неразрывный пробел (U+00A0) считается обычным: браузер сам подставляет его вcontenteditableвместо пробела, который иначе схлопнулся бы при отображении, и в значение он не попадает.
Очистка форматирования и действия панели
clearFormat() снимает все форматы сразу — с выделения, а без выделения со слова под кареткой (та же логика, что и у применения формата). Режим набора при этом сбрасывается. Распознаются и теги-синонимы (STRONG/EM/DEL/INS), которые могли прийти из вставки или setValue. clearAllFormat() чистит всё содержимое и выделения не требует.
Обе операции попадают в историю (откатываются одним Ctrl+Z) и не создают пустой шаг отмены, если очищать нечего.
Кроме кнопок форматирования панель может показывать кнопки действий — они подключаются явно через actions:
new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"] });| Действие | Кнопка | Что делает | Когда недоступна |
| --- | --- | --- | --- |
| emoji | Вставить смайлик | открывает панель вставки | в режиме readonly |
| erase | Очистить форматирование | clearFormat() | нет форматирования на выделении |
| undo | Отменить | undo() | история пуста |
| redo | Повторить | redo() | нечего повторять |
Кнопки действий (.action-button) стоят в одном ряду с инструментами (.format-button) и блоками (.block-button) — всё это правка оформления — и получают атрибут disabled, когда действие недоступно. Разделитель .split отбивает только кнопки хоста: они про другое. Панель показывается и в том случае, если инструментов форматирования нет, а действия заданы.
Панель смайликов
Кнопка emoji открывает под панелью попап .ui-richeditor-emoji со списком символов (EMOJIS — экспортируется пакетом). Выбранный символ вставляется через insertText(), то есть в текущую каретку и с учётом ожидающих форматов режима набора; попап после выбора закрывается.
Попап принадлежит своему владельцу: у панели свой, у хоста со своей кнопкой (например у поля сообщения) — свой. Собирает их общая createEmojiPicker(onPick), показывает — редактор: openEmojiPicker(picker, initiator) берёт на себя удержание правки на время попапа, каретку, если её ещё не было, и отпускание фокуса по keepFocus. Он же убирает панель форматирования, если попап раскрывается не из неё: два всплывающих слоя над одним полем вместе не показываются. Открытым в любом случае бывает один — за этим следит PopupManager кита.
Открытием и закрытием управляет PopupManager из @brandup/ui-kit — оттуда же приходят базовые стили .ui-popup. Ни кнопка, ни попап не забирают фокус сами (mousedown гасится), поэтому каретка и выделение сохраняются; на сенсорном устройстве поле отдаёт фокус намеренно (см. «Фокус на время своего слоя»), и вставка идёт по снятой каретке. Список кнопок собирается лениво, при первом открытии.
Попап — слой над полем, поэтому показывает его редактор (openEmojiPicker()): на время работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост со своей кнопкой собирает попап сам и передаёт его сюда — так делает @brandup/ui-messageeditor.
Первой группой в списке стоят недавние (.emoji-recent) — до двух рядов последних выбранных символов, свежий первым. Хранятся они в localStorage (ключ RECENT_EMOJIS_KEY), поэтому общие для всех попапов источника и переживают перезагрузку; пока ничего не выбрано — группы нет вовсе. Освежает её openEmojiPicker() при каждом показе: попап живёт между открытиями, а хранилище тем временем пополняют и другие попапы. Запоминается сам выбор, а не вставка — недавние про то, к чему тянутся. Недоступное хранилище (приватный режим) вставке не мешает — недавние просто не копятся. Пакет экспортирует recentEmojis(), rememberEmoji() и refreshRecentEmojis(picker) — хосту с собственным показом попапа освежать группу нужно самому.
Многострочный режим: абзацы и переносы
При multiline: true контент структурируется по абзацам:
- Enter → новый абзац (
<p>); - Shift+Enter или Ctrl/Cmd+Enter → мягкий перенос (
<br>) внутри абзаца; - блуждающий текст и
<div>нормализуются в<p>при вводе.
Опция paragraph: "break" меняет смысл абзаца: там абзац — это строка, как в мессенджерах. Enter и модификатор делают одно и то же (новую строку), мягкому переносу в этом режиме взяться неоткуда, а пустая строка сообщения — это пустой абзац. Это важно при storage: "markdown": в режиме по умолчанию граница абзацев уходит в значение пустой строкой (\n\n), а в break — одним переносом (\n).
Содержимое в обоих режимах — абзацные блоки: каждая строка (или абзац) лежит в своём <p>, а не разделяется <br> внутри общего. Отступов между абзацами в режиме break нет (класс breaks на редакторе): отступ читался бы пустой строкой, которой в значении не будет.
Мягкий перенос, пришедший извне — вставкой документа или чужим значением, — приводится к той же модели: абзац делится по нему на строки-абзацы. Хвостовой перенос при этом строкой не считается — это <br>-заполнитель, без которого не видна последняя (пустая) строка.
При нормализации (потеря фокуса, setValue, инициализация) пустые абзацы удаляются — кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается. В режиме break пустой абзац осмыслен сам по себе (это пустая строка сообщения) и не удаляется вовсе; исчезает только единственный — иначе пустое поле не показало бы заглушку.
Блоки: цитата и код
Кроме абзаца многострочный режим знает и другие типы блоков верхнего уровня — цитату и блок кода. Доступны они по умолчанию; поле, где они ни к чему, ограничивают пустым списком:
new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // только обычный текст| Тип | Тег | Разметка | Enter внутри |
| --- | --- | --- | --- |
| paragraph | <p> | — | по режиму paragraph |
| quote | <blockquote> | > с пробелом в начале каждой строки | заканчивает блок |
| code | <pre> | ограждение ``` | заканчивает блок |
Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (.block-button) получают только остальные типы.
Цитата рисуется плашкой по ширине содержимого, прижатой к левому краю: с подложкой пустое место справа от короткой строки читалось бы её частью. Длинная цитата переносится по границе редактора. Цвета и отступы задаются переменными --richeditor-quote-* (см. «CSS»).
Открывающая ограда в чужом маркдауне часто приходит с меткой языка (```text) — блок она открывает так же, а сама метка отбрасывается: значение кита её не хранит, обратно уезжает голая ограда. Закрывает блок только голая ограда — та же строка с меткой внутри блока остаётся его содержимым.
Ссылка
link — единственный инструмент, у которого есть данные: адрес идёт отдельной частью и в тексте не показывается. Поэтому он и устроен иначе остальных.
В разметке. Пара маркеров тут не годится, и в реестре у него md: "" — этим он выводится из всей маркерной механики. Разбор и сборку он делает своими ветками: [текст](адрес) в markdown, <a href> в html. Адрес прячется от маркеров до их разбора — иначе example.com/a_b_c уезжал бы курсивом. Текст ссылки от них не прячется: разметка внутри него разбирается наравне с остальной, [**жирный**](адрес) работает.
Адрес читается и в угловых скобках — [текст](<адрес с пробелом>); пишется в них же, когда иначе не прочитается. Парные скобки внутри адреса разрешены и без угловых: ими кончается половина ссылок на википедию. Скобки в тексте экранируются обратной косой.
Ссылки без текста или без адреса не бывает — такая разметка остаётся текстом. Перенос строки внутри ссылки тоже: её текст в разметке один кусок, разорванный он не выражается и с разбора не вернётся. Enter внутри ссылки её заканчивает — за переносом остаётся обычный текст, а не вторая ссылка с тем же адресом. Схема адреса проверяется: javascript:, data: и прочее исполняемое ссылкой не становится ни при разборе значения, ни при вставке из буфера.
В работе. Кнопка не переключатель — по ней панель показывает поле адреса вместо кнопок (Ctrl/Cmd+K делает то же). Не выпадающий слой: то же место, та же коробка — позиционировать и ужимать ничего не приходится, а кнопки на это время всё равно не нужны. Повторное нажатие возвращает их. Каретка в готовой ссылке подставляет её адрес: тогда это правка, а не новая ссылка. Пустой адрес ссылку снимает, для этого же есть кнопка рядом с полем.
Ссылка — оформление текста, а не вставка: делать ссылкой нечего — кнопка погашена, и applyLink ничего не делает.
| | | | --- | --- | | Выделение | становится ссылкой | | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов; знаки внутри — часть слова, поэтому адрес или почта оборачиваются целиком | | Каретка в готовой ссылке | меняется её адрес — целиком, а не по куску выделения | | Каретка вне слова | кнопка недоступна: оборачивать нечего |
Адрес отдаёт currentLink, ставит и снимает applyLink(url); applyFormat("link") ничего не делает — переключением адрес не задать.
Поле адреса, в отличие от всего остального в панели, забирает фокус: без него не набрать. Выделение в редакторе при этом теряется, поэтому панель снимает каретку до перевода фокуса и возвращает её перед правкой — тем же снимком в символах (caretSnapshot/restoreCaret), которым пользуются окна хоста. Esc возвращает каретку, ничего не изменив.
Отпущенный фокус обычно убирает панель с экрана (suspend), но не пока правят адрес: поле лежит в самой панели, и вместе с ней ушло бы и оно. У панели смайликов слой чужой — она раскрывается от кнопки хоста, — поэтому там панель прячется как раз правильно. А вот фокус, ушедший мимо панели, правку заканчивает: держаться ей больше не на чем.
Одна кнопка на моноширинный и блок кода
Когда включены и инструмент code, и тип блока code, панель показывает одну кнопку — так это устроено в мессенджерах. Вид выбирается по выделению, как и в самой разметке:
- выделена одна строка или её часть → моноширинный (
`код`); - выделено больше одной строки → блок кода из этих строк. Остальные строки остаются как были: в мессенджерском режиме всё сообщение — это один блок, и иначе кодом становился бы весь текст;
- без выделения → блок кода из строки под кареткой. Моноширинным там делать нечего, а иначе блок был бы недостижим: в пустом поле не выделить строки, которых ещё нет;
- код уже включён → повторное нажатие снимает именно его вид, целиком по блоку.
Тем же занимается applyCode(), а isCodeActive() отвечает, включён ли код в любом виде — им подсвечена кнопка.
Перенос строки в моноширинном тоже не живёт: значение берёт оттуда голый текст, и строка пропала бы — поле показывало бы две, а получатель увидел одну. Поэтому Enter разрезает моноширинный: форматирование продолжается на новой строке, только если там что-то осталось.
В коде разметки нет — ни в моноширинном, ни в блоке: значение берёт оттуда голый текст, и любое форматирование внутри до получателя не доедет. Поэтому при переходе в моноширинный прежнее форматирование с этого куска снимается, внутри кода остальные инструменты недоступны (isToolEnabled() — кнопки гаснут), а разметка, попавшая внутрь как-то ещё (вставка, чужое значение), вычищается при первой же нормализации. Если включено только что-то одно, кнопка остаётся обычной: инструмента (.format-button) или блока (.block-button).
Переключение:
- кнопка панели или
applyBlock(type)— на все блоки, которых касается выделение; повторное применение того же типа возвращает обычный текст; - Enter внутри цитаты или кода заканчивает блок и начинает обычный абзац, Shift/Ctrl/Cmd+Enter переносит строку внутри блока. Выходить из блока приходится чаще, чем продолжать его, а иначе уйти можно было бы только мышью;
- Backspace в начале блока возвращает его к обычному тексту, не трогая содержимого.
Особенности блока кода: инлайновое форматирование внутри не размечается (написанное остаётся буквальным) и снимается при переключении в этот тип, а пробелы внутри не схлопываются — отступы там часть текста.
Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. В режиме мягких переносов пустой строки между блоками нет, поэтому соседние цитаты там склеиваются в одну сразу в поле — иначе оно показывало бы два блока, а в значении и у получателя был бы один. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
В режиме paragraph: "break" блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два. Выделенные строки становятся при этом одним блоком, а не блоком на каждую: строка там — отдельный абзац, и кнопка на трёх строках дала бы три цитаты подряд.
Вставка текста
При включённом форматировании вставка (paste) сохраняет форматирование из буфера обмена (text/html):
- разметка санитизируется до включённых инструментов (синонимы
STRONG/EM/DEL/INS→ каноническиеb/i/s/u, всё прочее —span, стили, классы,<style>/<script>— отбрасывается, текст сохраняется). Из атрибутов остаётся толькоhrefу ссылки, и то по проверенной схеме; - multiline сохраняет абзацы
<p>и мягкие переносы<br>, разбивая текущий абзац по каретке; single-line — инлайн, абзацы/переносы становятся пробелами; - хук
filterPasteостаётся в силе: вернулnull— вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
При storage: "markdown" разметку сохраняет и вставка простого текста: раз значение хранится этой разметкой, маркеры во вставляемом тексте значат то же, что в значении, и разбираются тем же deserialize — с теми же наборами инструментов и блоков. Ограничение действует и здесь: при любой вставке применяется только включённый формат — маркер снятого инструмента остаётся текстом, как остался бы и в значении. Изменённый хуком filterPaste текст вставляется буквально, а внутри блока кода текст литерален всегда.
text/html точнее описывает скопированное, но лишь когда несёт собственную разметку. Голый текст тоже приезжает html-ем — редакторы кода отдают исходник маркдауна строками в <div> внутри общей обёртки, — и такой «плоский» html не знает ничего сверх text/plain, а маркеры в тексте понимает только разбор разметкой хранения: при плоском html первым идёт он. Границы строк-<div> при этом становятся переносами — склейка соседних строк встык потеряла бы и текст, и разметку.
Если text/html нет (или форматирование выключено), а разбирать маркеры не по чему, вставляется простой текст — по той же модели абзацев:
- multiline, режим
block— пустая строка разделяет абзацы<p>, одиночный перенос остаётся мягким<br>; - multiline, режим
break— каждая строка становится абзацем<p>, пустая строка — пустым абзацем; - single-line — строки склеиваются пробелами.
Каретка во всех случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
Бросок файла
Текстовый файл, брошенный в редактор, вставляется содержимым — тем же путём, что вставка из буфера: хук filterPaste, разбор разметкой хранения включённым набором, один шаг истории. Текстовым считается файл с типом text/*, а без типа — .md/.markdown/.txt/.text по имени: у маркдауна тип в системе часто не зарегистрирован. Несколько файлов вставляются подряд через пустую строку. Каретка встаёт в точку броска, если браузер умеет её назвать (caretPositionFromPoint/caretRangeFromPoint). Любой другой бросок гасится: свободное перетаскивание прошло бы мимо истории и фильтров хоста.
Формат хранения
| storage | Хранение | Абзац / мягкий перенос | Маркеры форматирования |
| --- | --- | --- | --- |
| html | Санитизированный HTML | <p>…</p> / <br> | <b>, <i>, <s>, <u>, <spoiler>, <code> |
| markdown | Лёгкая разметка | \n\n / \n | **жирный**, _курсив_, ~зачёркнутый~, __подчёркнутый__, \|\|спойлер\|\|, `моноширинный` |
Без форматирования (plain) значение хранится как markdown без инструментов: абзацы \n\n, мягкий перенос \n.
Маркер того же инструмента из чужого диалекта разбирается наравне со своим: *жирный* — родная разметка WhatsApp, и так размечены сообщения, набранные до редактора. Показывать их звёздочками значит показывать не то, что увидит получатель.
Переписывать под свой маркер при этом нельзя — открыть и закрыть сообщение меняло бы текст, — поэтому редактор запоминает, чем текст был размечен, и возвращает в значение то же самое. Настроенный маркер ставится только на то, что отформатировали в поле. Настройка меняет их местами: заданный markers.bold = "*" делает чужим диалектом уже **.
Маркеры markdown настраиваются через markers. При разборе применяются по убыванию длины, поэтому длинный маркер срабатывает раньше короткого-префикса. Глубоко вложенные комбинации гарантированно сохраняются только в режиме html.
Вложенные пары разбираются (_а **б** в_), а вот пересекающиеся остаются текстом: в **а _б** в_ внутренняя пара пересекает внешнюю, разметкой такое невыразимо, и короткий маркер отбрасывается — как и в мессенджерах.
Разметка распознаётся по правилам мессенджеров: маркер стоит на границе слова, содержимое не начинается и не заканчивается пробелом и не пересекает перенос строки. Поэтому 5**4 = 20, 2 ** 2 ** 2 и файл_имя_файла.txt остаются обычным текстом — иначе редактор показывал бы форматирование там, где получатель увидит исходные символы. Форматирование применяется к словам целиком, так что собственный вывод редактора всегда разбирается обратно.
Краевые пробелы при сериализации выносятся за маркеры (<b> слово </b>дальше → **слово** дальше), иначе разметка не сработала бы ни у нас, ни у мессенджера. А вот формат, приклеенный к соседнему слову — такое приходит только со вставкой внешнего HTML (супер<b>бонус</b>), — маркерами невыразим: значение сохранится как супер**бонус** и разметкой уже не станет. Это осознанное решение: текст остаётся ровно тем, что набрал пользователь, а не молча теряет форматирование.
CSS
Подключается richeditor.less. Классы: focused на обёртке (поле в фокусе), visible на общей панели (показана), active на кнопке инструмента (формат активен на выделении).
Собственные переменные пакета объявлены в :root в начале richeditor.less — там же и их значения по умолчанию:
| Переменная | По умолчанию | Что задаёт |
| --- | --- | --- |
| --richeditor-quote-line | rgba(0,0,0,.2) | Линия слева у цитаты |
| --richeditor-quote-line-width | 3px | Толщина линии цитаты |
| --richeditor-quote-fill | rgba(0,0,0,.04) | Подложка цитаты |
| --richeditor-quote-padding-tb | 5px | Вертикальный отступ цитаты — тот же, что у абзаца, чтобы строка не прыгала при смене типа блока |
| --richeditor-quote-padding-lr | 10px | Горизонтальный отступ цитаты — насколько текст отходит от линии и от края подложки |
| --richeditor-code-fill | rgba(0,0,0,.06) | Подложка кода — и моноширинного, и блока |
| --richeditor-code-font | ui-monospace, … | Шрифт кода |
| --richeditor-spoiler-fill | rgba(0,0,0,.14) | Плашка спойлера |
| --richeditor-link-width | 320px | Ширина панели при правке адреса, когда она стоит в document.body — растягиваться там не по чему |
| --richeditor-link-max-width | 500px | Предел ширины растянутой панели при правке адреса |
| --richeditor-link-color | #2481cc | Цвет ссылки в тексте |
| --richeditor-link-underline-offset | auto | Отступ подчёркивания ссылки от базовой линии |
| --richeditor-underline-room | 2px | Место под подчёркивание последней строки: рисуется оно ниже текста, но в раскладке места не занимает, и без запаса его срезает край прокручиваемой коробки |
| --richeditor-toolbar-padding | 3px | Поля панели |
| --richeditor-toolbar-button-size | 34px | Кнопка панели; по ней же высота поля адреса |
| --richeditor-toolbar-edge-gap | 4px | Зазор панели от краёв экрана; то же значение зашито константой в позиционировании (EDGE_GAP), менять их нужно вместе |
| --richeditor-emoji-size | 32px | Ячейка в панели смайликов |
| --richeditor-emoji-rows | 8 | Запасная высота нарисованной не сразу группы; своё значение панель ставит на каждую группу |
Остальное оформление берётся у полей ввода @brandup/ui-kit — --input-*, --hover--input-*, --focus--input-*, --placeholder-*, --svg-*. В :root пакет их не объявляет: объявив, он перекрыл бы значения кита. Запасные значения у них стоят по месту — на случай использования пакета без кита.
