npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@brandup/ui-richeditor

v1.0.55

Published

Rich text editor over a contenteditable element (bold, italic, strike, underline).

Downloads

3,018

Readme

@brandup/ui-richeditor

Build Status

Редактор текста на базе 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 пакет их не объявляет: объявив, он перекрыл бы значения кита. Запасные значения у них стоят по месту — на случай использования пакета без кита.