Olddevs UI3.38.0
Справочник

RichTextEditor

Кратко для AI

RichTextEditor — поле форматированного текста на Tiptap v3 (ProseMirror). Живёт отдельным входом @olddevs/ui/editor, не в barrel, а движок — опциональные peer-зависимости, которые потребитель ставит сам. Значение — HTML-строка, ограниченная Схемой; пустой документ — "". Высота: по умолчанию поле растёт с Документом; maxHeight (пиксели или CSS-длина) ограничивает область Документа — он прокручивается внутри рамки, Панель и Строка статуса на месте, — а resizable даёт ей ручку высоты (полоса с хватом и ползунок с клавиатуры). Режимы value/onChange и defaultValue, name для нативной формы, подпись и ошибка — от FormField. Панель (toolbar) — липкая над полем, плавающая над выделением или компактное меню выделения на инверсной поверхности ("bubble"; на сенсорном экране — всегда липкая), Alt+F10 переводит в неё фокус; у Блока с кареткой слева — ручка «⋮» с меню действий над Блоком (добавить ниже, формат, вверх, вниз, дублировать, удалить; Ctrl/⌘+/); ссылка правится в поповере по Ctrl/⌘+K; картинка — <figure> с подписью и шириной 50/75/100 %, вставка по адресу, а с onUploadImage(file) → Promise<url> — ещё из буфера, перетаскиванием и выбором файла (base64 в документ не пишется никогда). Блок кода — с шапкой (язык слева, «Копировать» справа), языком (Python, TypeScript, JavaScript, Go, HTML, JSON, YAML, Markdown или обычный текст) и подсветкой lowlight; в документ пишется только class="language-…", а для RichTextView блоки подсвечивает серверный хелпер из @olddevs/ui/editor/highlight. Кнопка «HTML» фиксированной Панели (инструмент source) меняет Документ на поле с его исходным HTML; выход, blur, отправка формы и getHTML() применяют правку через Схему, как входящий value. Набор возможностей — preset="full" | "basic" или точный список tools; выключенный инструмент убирает и кнопку, и разметку из Схемы. Экран управляет полем через ref (insertHTML, insertText, getText, getSelectedText, isEmpty, focus(position?), blur, undo, redo) и строит свою панель на хуке useRichTextEditorState(ref, selector); до Готовности (на сервере, до монтажа, пока работают загрузчики Инструментов) методы тихие — раздел «Хэндл и своя панель экрана». До Готовности поле показывает Скелет — рамку и Документ статичным HTML без ввода, без прыжка высоты; Документ Скелета — value, очищенный Схемой, и только в браузере: на сервере Скелет — рамка без Документа; Часть для движка своего Инструмента можно передать загрузчиком (engine: () => import(…)), его сбой — ошибка поля с «Повторить». О событиях поля экран узнаёт колбэками onReady(), onFocus() и onBlur() — раздел «События»; свой Инструмент вмешивается во вставку и drop только Частью для движка. Под полем внутри рамки — Строка статуса (status, React-узел экрана у начала, счётчик кита в конце, без role) и Сообщение поля: команда движка showNotice({ tone: "status" | "error", text }) / showNotice(null) — status только озвучивается, error видна строкой role="alert" до следующей правки; тем же каналом идут сообщения о картинках. Свой Инструмент (CustomTool в tools) может нести кнопки в поле buttons: подпись (строка или функция от { locale }), значок, can, isActive, isVisible, selectionMenu, shortcut и ровно одно из run и popover; кит рисует кнопку одной группой перед «Отменить» и «Повторить», вешает сочетание и показывает его в подсказке, aria-keyshortcuts и справочнике. Ещё один вид записи — Карточка у текста (callouts): диапазон rangeAt(state, pos) (для Метки — готовый markRange("acme_comment")) и тело render({ editor, range, close }); кит открывает её кареткой в диапазоне или щелчком, держит рамку (слой, якорь под диапазоном, переворот у края, Escape), не забирает фокус из Документа и ведёт в неё по Alt+F10. Ещё два вида записей — пункты Меню вставки (insertItems: «/», «+», «Добавить ›» и «Формат ›» Ручки) и Действия над Блоком (blockActions: секция меню Ручки перед «Удалить»), тоже с ровно одним из run и popover: поповер пункта встаёт у каретки, Действия — у Блока. Имена своего — с Префиксом потребителя (acme_card), HTML своего Блока — div или span с data-block, Метки — span с data-mark, атрибуты — data-acme_* со значением по правилу (список, шаблон, адрес); пресеты — массивы RICH_TEXT_PRESETS.full и .basic, чтобы дописать к ним своё. Тот же массив tools принимают серверная очистка (sanitizeRichText(html, DOMPurify, tools), richTextSchema(tools)) и RichTextView (Остров своего Блока — поле island). Атом весит в maxLength длину своего Текста атома (renderText): разделитель — 0, мягкий перенос — 1, картинка — длину подписи. Всё это одной картой, граница semver («Стабильность») и рецепты — API расширения Редактора.

import { RichTextEditor } from "@olddevs/ui/editor";

Когда использовать

  • Длинный текст с форматированием: статья, описание, регламент, предложение клиенту. Автор пишет заголовки, списки, цитаты и выделяет слова.
  • Значение хранится и отдаётся как HTML, а показывается потом страницей.
  • Компонент «на будущее» (ADR-0048): экрана-заказчика при выпуске не было, API взят по аналогам — Imperavi Redactor 5, Tiptap Simple Editor, minimal-tiptap, Notion. Ваш экран — первая проверка этого API, а не нарушение контракта. Решение о движке — ADR-0050.

Когда не использовать

  • Простой многострочный текст без разметки — TextControl as="textarea". Редактор тащит движок на 200–240 кБ gzip; комментарий в две строки этого не стоит.
  • Код или конфигурация — это не форматированный текст. Показ кода — CodeView; редактора кода в ките нет (ADR-0039).
  • Показ сохранённого документа без правки. readOnly держит движок живым; для страницы просмотра есть RichTextView — без движка и годится для серверного компонента.

Установка

Движок — опциональные peer-зависимости: кто вход @olddevs/ui/editor не импортирует, ничего не ставит, и обновление кита Tiptap ему не притащит. Кто импортирует — ставит одной командой:

npm install @tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/extensions \
  @tiptap/extension-list @tiptap/extension-subscript @tiptap/extension-superscript \
  @tiptap/extension-text-style @tiptap/extension-highlight @tiptap/extension-text-align @tiptap/extension-link \
  @tiptap/extension-table @tiptap/extension-blockquote @tiptap/extension-horizontal-rule \
  @tiptap/markdown marked @tiptap/suggestion \
  @tiptap/extension-code-block-lowlight lowlight highlight.js

Пакеты ставятся все, даже если ваш набор инструментов без чек-листа или индексов: вход импортирует расширения статически, а выбор tools происходит уже при создании поля. Блок кода и таблица приходят отдельным чанком (раздел «Скелет»), но их пакеты сборщик всё равно разрешает при сборке — @tiptap/extension-table, @tiptap/extension-code-block-lowlight, lowlight и highlight.js нужны и экрану с basic. @tiptap/extension-blockquote и @tiptap/extension-horizontal-rule npm и yarn ставят вместе с @tiptap/starter-kit, но в строгом node_modules pnpm их нужно назвать явно — команда выше их уже содержит. Плавающая Панель берёт позиционирование из @tiptap/react/menus: нужный ей @tiptap/extension-bubble-menu (с @floating-ui/dom) — необязательная зависимость самого @tiptap/react, и менеджер пакетов ставит его вместе с ним. Если вы ставите пакеты с --omit=optional, добавьте его явно.

Входов у редактора три. @olddevs/ui/editor — сам редактор, просмотр и очистка; ему нужны peer-зависимости выше. @olddevs/ui/editor/schema — только Схема данными (RICH_TEXT_SCHEMA, richTextSchema(tools)), очистка sanitizeRichText(html, DOMPurify), закрытый конфиг richTextSanitizeConfig() для сервера и помощник Карточки у текста markRange: ни React, ни Tiptap в его графе нет, ничего ставить не нужно (DOMPurify приносит потребитель). @olddevs/ui/editor/highlight — подсветка без Редактора: highlightCodeBlocks(html) для сохранённого Документа (раздел «Блок кода» ниже) и highlightCodeNodes(code, language) — те же токены узлами для CodeView; в его графе lowlight, highlight.js и react. Очистка с DOMPurify — на странице RichTextView.

В Next.js вход @olddevs/ui/editor добавляется в optimizePackageImports отдельной строкой рядом с @olddevs/ui (getting-started.md): без неё страница, импортирующая из входа один RichTextView, отгружает в браузер весь Редактор с движком.

Версии пакетов Tiptap держатся одинаковыми: они ссылаются друг на друга точной версией. Диапазон — ^3.31.4.

Отдельный @source для Tailwind не нужен: классы редактора лежат в том же dist, что и остальной кит, и строка @source "./node_modules/@olddevs/ui/dist" из getting-started.md их уже покрывает.

Минимальный пример

import { FormField } from "@olddevs/ui";
import { RichTextEditor } from "@olddevs/ui/editor";

<FormField label="Описание" htmlFor="description" hint="Видно на странице обзора">
  <RichTextEditor id="description" value={html} onChange={setHtml} maxLength={5000} />
</FormField>;

Панель

Фиксированная Панель стоит над полем и липнет при прокрутке под шапкой приложения: отступ — CSS-переменная --topbar-h, которую публикует TopBar кита (ADR-0016, ADR-0019). Без шапки переменной нет, и Панель липнет к краю окна. Липкость — внутри рамки поля: Панель уходит вместе с полем, когда оно прокручено целиком. По умолчанию своего скролла у Редактора нет — он растёт вместе с документом; предел высоты и ручку высоты включают maxHeight и resizable (раздел «Высота поля»).

  • toolbar="fixed" (по умолчанию) — Панель над полем, а над непустым выделением ещё и меню выделения — та же плашка, что в toolbar="bubble" (выключается selectionMenu={false}; на сенсорном экране плашки нет); toolbar="floating" — Панель над непустым выделением (ниже); toolbar="bubble" — вместо Панели меню выделения, компактная плашка над выделением (ниже); toolbar="none" — без Панели, форматирование остаётся за клавиатурой.

  • В readOnly Панели нет. В disabled Панель видна, но все кнопки выключены и фокуса не берут.

  • Справочник сочетаний — последняя кнопка Панели, с клавиатурой, у правого края; есть только у фиксированной Панели (над выделением и в readOnly её нет, при toolbar="none" Панели нет вовсе). Подробнее — «Справочник сочетаний».

  • Раскладка — один компактный ряд, порядок задаёт кит, а не tools (OLDSUI-66). Группы через разделитель:

    ГруппаКнопки и меню по порядку
    Блок целиком«+» Меню вставки · HTML · «¶» тип Блока · Выравнивание ▾
    Меткижирный · курсив · маркер · «…» (остальные метки, блок кода, цвета, очистка)
    СпискиСписки ▾
    Вставкассылка · картинка · Таблица ▾ · эмодзи
    Историяотмена · повтор

    У правого края — справочник сочетаний. Кнопка или меню без включённого инструмента не рисуется, группа без кнопок пропадает вместе со своим разделителем: в basic ряд — «¶» | жирный, курсив, «…» | Списки ▾ | ссылка | отмена, повтор. Та же раскладка у плавающей Панели (toolbar="floating"), только без справочника; у меню выделения (toolbar="bubble") свой короткий состав — без выравнивания и списков (ниже).

  • Отдельных кнопок выравнивания, списков, цитаты, горизонтальной линии и блока кода на Панели нет: выравнивание и списки — меню, цитата — пункт «¶», линия — пункт Меню вставки («+» и /), блок кода — пункт «…» (им пользуются нечасто, OLDSUI-76). Полный ряд помещается одной строкой в колонке 640 px; на узком экране Панель переносится на вторую строку, а не уезжает за край.

Меню «¶» — тип Блока

Кнопка «¶» открывает меню типа Блока под кареткой: Текст (Ctrl/⌘+Alt+0), Заголовок 1 (только с heading1, Ctrl/⌘+Alt+1), Заголовок 2–4 (Ctrl/⌘+Alt+2…4), Цитата (Ctrl/⌘+Shift+B), Маркированный, Нумерованный список и Чек-лист (Ctrl/⌘+Shift+8, +7, +9). Сочетание подписано у каждого пункта: ⌘, ⌥, ⇧ через плюс на Mac и iOS (⌘+⌥+2), Ctrl, Alt, Shift у остальных.

  • Пункты — из включённых инструментов (heading, heading1, blockquote, bulletList, orderedList, taskList); «Текст» есть всегда, пока есть хоть один другой пункт. Без единого такого инструмента кнопки «¶» нет. В basic — Текст, Цитата и два списка.
  • Отмечен текущий тип — и внутри списка или цитаты: абзац в пункте списка — это «Маркированный список». Из двух обёрток решает ближняя к тексту (список в цитате — список). Значок кнопки — всегда «¶»: она не меняет вид при каждом движении каретки, текущий тип видно отметкой в открытом списке, а в доступном имени он назван: «Тип блока: Заголовок 2». В блоке кода кнопка называет «Блок кода», а отмечен ни один пункт: блока кода в меню нет, он — пункт «…».
  • Выбор меняет тип Блока с сохранением текста — тем же путём, что раздел «Превратить в» Меню вставки: обёртки (список, цитата) снимаются, новый тип ставится одной правкой, Ctrl/⌘+Z отменяет её целиком. Выбор текущего типа ничего не меняет. Фокус возвращается в Документ.
  • Кнопки цитаты на Панели нет — цитата в «¶». Виды списка выбираются и здесь, и в меню «Списки ▾» (ниже).

Меню «…» — More formatting

Кнопка «…» (подсказка и имя — «Ещё форматирование», editor.moreFormatting) стоит сразу за кнопкой маркера и открывает меню остального форматирования: Строчный код (Ctrl/⌘+E), Подчёркивание (Ctrl/⌘+U), Зачёркнутый (Ctrl/⌘+Shift+S), Верхний индекс (Ctrl/⌘+.), Нижний индекс (Ctrl/⌘+,), Блок кода (Ctrl/⌘+Alt+C), подменю Маркер › и Цвет текста ›, Очистить стили (Ctrl/⌘+\). Сочетания подписаны у пунктов — из той же таблицы, что подсказки Панели и справочник. Сочетаний из привычных образцов (⌘H, ⌘L, ⌘⇧M) нет: их перехватывают macOS и браузер, и подпись обещала бы то, что не срабатывает.

  • Пункты — из включённых инструментов (code, underline, strike, superscript, subscript, codeBlock, highlight, color, clearFormatting). Без единого из них кнопки «…» нет; с одним — меню из одного пункта. В basic в «…» строчный код и зачёркнутый. Метка, которая у хозяина стоит кнопкой в ряду, в его «…» не повторяется: в меню выделения нет подчёркнутого — он там в ряду.
  • Метки — пункты с отметкой (menuitemcheckbox, aria-checked): отмечены Метки выделения или каретки. Выбор ставит или снимает Метку одной правкой (Ctrl/⌘+Z отменяет её целиком), закрывает меню и возвращает фокус в Документ. Пункт, которому здесь нечего делать (Метка в блоке кода), недоступен.
  • Блок кода — отдельный раздел сразу за Метками (OLDSUI-76; прежде — кнопка Панели между «HTML» и «¶»). Это Блок, а не Метка, поэтому за разделителем, но такой же пункт-флажок с сочетанием: все переключатели «…» идут подряд, подменю цвета и «Очистить стили» — после них. Отмечен, когда каретка в блоке кода; выбор превращает Блоки выделения в блок кода или возвращает его в текст — одной правкой, как Ctrl/⌘+Alt+C. Недоступен там, где движок блок кода не ставит. Тот же пункт — в «…» меню выделения: кнопки блока кода у плашки не было, а превратить выделенные строки в код удобно именно оттуда.
  • Маркер › — три цвета словом (жёлтый, зелёный, розовый), «Без маркера», «Свой цвет…»; Цвет текста › — палитра текста, «По умолчанию», «Свой цвет…». Пункты-радио (menuitemradio), текущий отмечен. Имя пункта «…» называет текущий цвет — «Маркер: Розовый», «Цвет текста: Свой #00aa55», — и его слышно, не раскрывая подменю; там же назван прежний читаемый маркер («Маркер: Синий»), хотя пункта у него нет.
  • Подменю открывают → и наведение, ← и Escape закрывают по одному уровню: подменю, затем меню (фокус — на кнопку «…»), и только потом окно или ящик вокруг Редактора.
  • Отдельных кнопок строчного кода, подчёркивания, зачёркнутого, индексов, блока кода, очистки и двух меню цвета на Панели больше нет: «…» их заменяет. Меню собрано в общем модуле меню форматирования (MoreFormatMenu в FormatMenus.tsx) — то же «…» стоит и в меню выделения.
  • Маркер в ряду — один щелчок жёлтым (кнопка «Маркер», editor.highlightToggle, решение владельца, OLDSUI-60): ставит жёлтый маркер на выделение, а если на нём уже есть любой маркер — снимает его; кнопка нажата (aria-pressed), пока маркер есть. Выбор цвета — в подменю «Маркер ›» того же «…». Своего сочетания у кнопки нет. Ряд частых меток общий для Панели и плашки (rowMarks.ts): Панель — жирный, курсив, маркер; плашка — ещё и подчёркнутый.

Меню «Выравнивание» и «Списки»

Оба — меню того же общего модуля, что «¶» и «…» (AlignMenu и ListMenu в FormatMenus.tsx): пункты-радио (menuitemradio) с отметкой текущего значения и подписями сочетаний из общей таблицы. Значок кнопки — значок текущего значения, имя — значение словами; выбор возвращает фокус в Документ и отменяется одним Ctrl/⌘+Z.

  • Выравнивание ▾ (инструмент align): По левому краю (Ctrl/⌘+Shift+L), По центру (Ctrl/⌘+Shift+E), По правому краю (Ctrl/⌘+Shift+R). Имя кнопки — «Выравнивание: по центру»; у Блока без data-align отмечено «По левому краю», у выделения со смешанным выравниванием — тоже. Выбор отмеченного пункта ничего не меняет и пустой правки в историю не кладёт; к умолчанию ведёт «По левому краю». По ширине нет — решение ниже, в «Цвет, маркер и выравнивание» (WCAG 1.4.8).
  • Списки ▾ (любой из bulletList, orderedList, taskList): Маркированный список (Ctrl/⌘+Shift+8), Нумерованный список (Ctrl/⌘+Shift+7), Чек-лист (Ctrl/⌘+Shift+9) — те же пункты, что в «¶», только из включённых инструментов. Отмечен вид ближайшего списка вокруг каретки (и в цитате внутри пункта), имя кнопки — «Список: нумерованный», вне списка — «Списки» с общим значком списка. Выбор другого вида идёт тем же путём, что «¶»: Блок выходит из списка и цитаты и оборачивается заново одной правкой. Повторный выбор отмеченного снимает список, как прежняя кнопка-переключатель. Меню с одним включённым видом остаётся меню.
  • Без инструмента align нет меню выравнивания, без единого вида списка — меню «Списки». В basic — «Списки» с двумя пунктами, выравнивания нет.

Плавающая Панель

toolbar="floating" показывает ту же Панель — те же кнопки, тот же порядок — над выделенным текстом. Пустое выделение (просто каретка) Панель прячет, и тогда её нет ни на экране, ни в обходе Tab, ни у скринридера. Показ идёт с короткой задержкой после того, как выделение перестало меняться: пока мышь тянет выделение, Панель не мигает. Позицию и переворот у края экрана считает BubbleMenu Tiptap (floating-ui).

  • Сенсорный экран — фиксированная Панель. Если основной указатель — палец (pointer: coarse: телефон, планшет), Редактор рисует фиксированную Панель вместо плавающей: над выделением на iOS и Android стоит системное меню «Копировать / Вставить», и плавающая Панель спорила бы с ним за одно место. Медиазапрос слушается на лету — планшет с подключённой мышью получит плавающую.
  • Панель лежит в DOM Редактора, а не в портале: она едет вместе с текстом в любом контейнере прокрутки (окно, ящик, карточка). Поэтому предок с overflow: hidden срежет её у своего края — не ставьте Редактор в такой контейнер вплотную к верху, или берите toolbar="fixed".
  • Слой — ступень OVERLAY_LAYER.popover, как у поповеров. В стеке Escape Панель не участвует: закрывать в ней нечего, и Escape остаётся окну или ящику вокруг Редактора.

Меню выделения: selectionMenu и toolbar="bubble"

Плашка меню выделения по умолчанию появляется и поверх фиксированной Панели (toolbar="fixed", проп selectionMenu, по умолчанию true): Панель сверху — для всего, плашка — чтобы не уводить мышь от текста. Alt+F10 в этом случае ведёт в Панель. toolbar="bubble" ставит меню выделения вместо Панели — компактную плашку над выделенным текстом, как в Notion и Medium. Она на инверсной поверхности: тёмная в светлой теме, светлая в dark и dim, — и поэтому не сливается ни с текстом, ни с полем под ним.

  • Состав задан китом и не настраивается: «¶» (тип Блока, то же меню, что на Панели) · жирный · курсив · подчёркнутый · маркер (один щелчок жёлтым) · «…» (остальные метки и блок кода — то же меню, что на Панели, но без подчёркнутого: он здесь в ряду) · ссылка (тот же поповер, что у Ctrl/⌘+K) · эмодзи (та же сетка). Кнопка выключенного инструмента не рисуется: в basic нет эмодзи, при tools={["italic", "link"]} остаются две кнопки. Отмены, повтора, справочника сочетаний и редких меток в плашке нет — она про выделенный текст, а не про Документ.
  • Жирный, курсив, подчёркнутый и маркер — переключатели с aria-pressed; подсказка называет сочетание из той же таблицы, что у Панели, кнопка объявляет его aria-keyshortcuts.
  • Показ и позиция — как у плавающей Панели: плашка появляется, когда выделение устоялось, и прячется на пустом выделении, на выделенной картинке (у неё своя панель) и когда фокус ушёл из Редактора. У края окна или контейнера прокрутки она переворачивается под выделение и сдвигается внутрь. В readOnly плашки нет.
  • Сенсорный экран — фиксированная Панель, по той же причине, что у floating: системное меню выделения iOS и Android стоит на том же месте.
  • Меню и поповеры, открытые из плашки, — на обычной поверхности поповера, а не на инверсной: «¶», «…», поле ссылки и сетка эмодзи выглядят так же, как с Панели. Escape в открытом меню закрывает только меню и возвращает фокус на его кнопку в плашке; внутри Dialog и Drawer окно остаётся открытым.
  • Плашка лежит в DOM Редактора, как плавающая Панель: едет вместе с текстом в контейнере прокрутки, а предок с overflow: hidden срежет её у своего края. Слой — OVERLAY_LAYER.popover.
  • Имя плашки — editor.selectionToolbar («Форматирование выделения» / «Selection formatting»): скринридер отличает её от Панели.
  • Цвета — токены инверсной поверхности --surface-inverse, --surface-inverse-hover, --surface-inverse-pressed, --text-on-inverse и контур фокуса --accent-on-inverse (palette.md). На листе surface="paper" плашка остаётся в теме приложения, как Панель.

Стекло всплывающих поверхностей

Всё, что Редактор показывает поверх Документа, лежит на стекле — полупрозрачной подложке своей поверхности с размытием фона: плавающая Панель и панель картинки, меню выделения, списки меню («¶», «…», выравнивание, списки, таблица, Меню вставки, ручка Блока, язык блока кода, Слэш-меню) поповеры (ссылка, картинка, свой цвет, эмодзи, справочник сочетаний, поповеры кнопок, пунктов вставки и Действий над Блоком своего Инструмента) и Карточка у текста. Подложка — 82 % токена поверхности в color-mix, размытие — 14px, как у тостов (ADR-0010); подсказки остаются со своими 85 % и 10px.

  • Цвет — токен своей поверхности: Панель и поповеры — --bg-1, списки меню — --surface-elevated, меню выделения — --surface-inverse. Тема меняет токен, стекло меняется вместе с ним.
  • Сплошная подложка при prefers-reduced-transparency: reduce и prefers-contrast: more: под полупрозрачной подложкой контраст текста зависит от того, что лежит под меню, а пользователь, попросивший систему убрать прозрачность или поднять контраст, получает прежнюю сплошную поверхность без размытия.
  • Только Редактор. Menu и Popover кита вне Редактора остаются сплошными: стекло Редактор передаёт им классом, публичный API не менялся.

Клавиатура: Alt+F10

Alt+F10 (на Mac — ⌥F10) из документа переводит фокус на первое действие Панели — фиксированной, плавающей или меню выделения; плавающую Панель и плашку клавиша показывает сразу, не дожидаясь задержки. Сочетание то же, что у CKEditor и TinyMCE: у APG своего нет, а одно сочетание на все редакторы запомнить проще. Tab для этого не годится — в документе он сдвигает пункт списка.

  • Внутри Панели — стрелки, Home и End, как всегда. Действие возвращает фокус в документ на то же выделение; без действия обратно ведёт Shift+Tab — плавающая Панель стоит в DOM сразу за документом.
  • Escape Панель не перехватывает: в окне он закроет окно (см. «Доступность и форма»).
  • На пустом выделении у плавающей Панели и меню выделения и при toolbar="none" клавиша ничего не делает и уходит дальше.
  • Панель объявляет сочетание aria-keyshortcuts="Alt+F10" — скринридер называет его на самой Панели.
  • Из поля HTML-режима Alt+F10 ведёт на кнопку «HTML»: остальные действия Панели там aria-disabled, и она — первое, которое сработает (Enter на ней возвращает визуальный режим, стрелки идут по Панели дальше). Раздел «HTML-режим».
  • Открытая Карточка у текста ближе Панели: Alt+F10 ведёт в неё, Escape возвращает каретку; пока Карточка спрятана или закрыта, клавиша ведёт в Панель (раздел «Карточка у текста»).
  • С кареткой в блоке кода Alt+F10 ведёт сначала в кнопку языка этого блока, затем в «Копировать», затем — в Панель (раздел «Блок кода»). Так же на выделенной картинке клавиша ведёт в её панель.

Справочник сочетаний

Кнопка с клавиатурой («Сочетания клавиш» / «Keyboard shortcuts») — последняя на фиксированной Панели, у правого края. Она открывает Popover кита со списком «команда — сочетание» только для чтения: строки не нажимаются, это не палитра команд.

  • Разметка — таблица с заголовками столбцов «Команда» и «Сочетание»; сочетание — Kbd кита (<kbd>). Скринридер читает строку как пару «команда — сочетание».
  • Свои кнопки. Сочетание кнопки своего Инструмента (поле shortcut) попадает в справочник отдельной группой без заголовка после групп кита, в порядке tools; подпись берётся с локалью UiProvider. Клавиши Части для движка своего Инструмента сюда не попадают — раздел «Кнопка своего Инструмента».
  • Порядок групп: выделение (первое ⌘A — текст Блока, второе — весь Документ), отмена и повтор, команды Блоков (дублировать, вверх, вниз, меню ручки), очистить стили, метки (жирный, курсив, зачёркнутый, подчёркнутый, строчный код, индексы), тип Блока (текст, заголовки, цитата, блок кода), списки и сдвиг пункта (⌘] и ⌘[), выравнивание, ссылка, переход в Панель (Alt+F10).
  • Состав по tools. Строка есть только у команды включённого инструмента: в basic нет заголовков, подчёркивания, индексов, выравнивания, чек-листа и блока кода, а сдвиг пункта есть при любом из трёх списков. Заголовок 1 — только при heading и heading1 вместе. Всегда есть команды без своего инструмента: выделение, дублировать и переставить Блок, меню ручки Блока, Alt+F10.
  • Свои Инструменты. Сочетания их пунктов вставки и Действий над Блоком (поле shortcut) стоят группой без заголовка после групп кита, по Инструменту на группу, в порядке tools; подпись строки — подпись записи в текущей локали.
  • Ширина — по содержимому: поповер равен самой длинной строке, без пустого поля между командой и сочетанием; край экрана ограничивает его, длинный список прокручивается.
  • Подписи. Клавиши через плюс на всех платформах: ⌘+⇧+Z на Apple и Ctrl+Shift+Z у остальных. Слитная запись macOS (⌘⌥⇧↑) в длинном списке читается как одно слово. Признак Apple — тот же, что у ProseMirror, то есть тот, по которому движок решает, какая клавиша mod. Знаки ⌘ ⌥ ⇧ ⌃ Kbd рисует пропорциональным шрифтом: в моноширинном стеке ⇧ нет, и запасной глиф выходит тонким.
  • Одна таблица команд — источник правды. Подсказки и aria-keyshortcuts кнопок Панели, подписи в Меню вставки и справочник читают сочетания из одной таблицы (src/editor/shortcuts.ts: id команды, инструмент, клавиши, подпись из каталога). Привязки вешают расширения, а таблица их подписывает; чтобы подпись не обещала того, чего нет, tests/rich-text-editor-shortcuts.test.tsx нажимает каждое сочетание таблицы в живом Редакторе и проверяет результат.
  • Закрытие. Escape и щелчок снаружи закрывают поповер и возвращают фокус на кнопку (если щелчок сам поставил фокус на поле или кнопку, его не отбирают). Внутри Dialog и Drawer Escape закрывает только справочник, а не окно (ADR-0041).
  • Не показывается: в readOnly, при toolbar="none", над выделением в toolbar="floating" и в меню выделения (toolbar="bubble"); на сенсорном экране, где они заменены фиксированной Панелью, кнопка есть.

Строки каталога: editor.shortcuts (имя кнопки и поповера), shortcutsCommand и shortcutsKeys (заголовки столбцов), а также подписи команд без своей кнопки — selectBlock, selectDocument, duplicateBlock, moveBlockUp, moveBlockDown, blockActions, indentListItem, outdentListItem, focusToolbar.

Ручка Блока «⋮»

У Блока, в котором стоит каретка, слева от его первой строки появляется кнопка «⋮» — ручка Блока («Действия с блоком» / «Block actions»). Наведение мыши ручку не показывает (решение владельца): она не мелькает у каждого абзаца под указателем. Видна, пока фокус в Документе, на самой ручке или в её меню; в readOnly её нет, у выключенного поля — тоже. Под ручку у Документа в правке есть левое поле (у листа surface="paper" — шире на столько же), и текст она не перекрывает.

Щелчок или Ctrl/⌘+/ из Документа открывают меню — Menu кита:

ПунктДействие
Добавить ›Подменю с пунктами Меню вставки из включённых инструментов (абзац, заголовки, списки, цитата, блок кода, разделитель, таблица, картинка). Выбор ставит новый Блок под текущим и каретку в него; без insertMenu пункта нет
Формат ›Подменю с пунктами «¶» (Текст, Заголовок 1–4, Цитата, три вида списков): превращает Блок, текущий тип отмечен. Таблицу, картинку и линию превратить нельзя — пункт виден, но недоступен (aria-disabled)
Вверх, ВнизПереставить Блок с соседом; у первого и последнего Блока соответствующий пункт недоступен
ДублироватьКопия под Блоком, каретка — в копию
УдалитьКрасный пункт (тон danger пункта Menu). Удаляет Блок, каретка — в Блок выше (у первого — ниже); единственный Блок уступает место пустому абзацу
  • Блок — целиком. Ручка стоит у Блока Документа: абзац, заголовок, список целиком, чек-лист, цитата, блок кода, таблица, картинка, линия. Каретка во втором пункте списка — ручка у первой строки списка, и действия меню берут весь список; «Формат › Заголовок 2» превращает в заголовки все его пункты. Выделение через несколько Блоков ручки не имеет.
  • Подписи сочетаний — только когда сочетание делает то же. У абзаца или заголовка верхнего уровня пункты подписаны: ⌥⇧↑/↓, ⌘⇧D, сочетания «¶» в «Формат ›». В пункте списка ⌥⇧↑ двигает пункт, а не список, — подписей нет; в таблице ⌥⇧↑/↓ отданы строкам — у «Вверх» и «Вниз» подписи нет, а ⌘⇧D копирует таблицу целиком — у «Дублировать» есть. У «Добавить ›» подписей нет: сочетания меняют текущий Блок, а пункт ставит новый.
  • Одна правка на отмену, фокус после действия — в Документе. Escape закрывает подменю, затем меню, и возвращает фокус в Документ; внутри Dialog и Drawer окно при этом не закрывается (ADR-0041).
  • Клавиатура. Ручка — вне обхода Tab (в Документе Tab сдвигает пункт списка); к ней ведёт Ctrl/⌘+/ — как «действия с Блоком» в Notion, у браузеров и систем это сочетание в поле свободно. Строка есть в справочнике сочетаний; на ручке — aria-keyshortcuts.
  • Действия Инструментов. Свои Действия над Блоком (blockActions описания Инструмента, как «Снять обёртку» у Обёртки) стоят отдельной секцией перед «Удалить»: только у Блоков, чьи имена названы в blocks, и по предикату видимости; run получает позицию Блока. Свои пункты вставки — в «Добавить ›» и «Формат ›» (раздел «Свой Инструмент»).
  • Перетаскивания за ручку нет (решение владельца).
  • Раскладка (OLDSUI-74, раздел «Раскладка»). Колонка — контейнер Блоков, как Документ: у Блока в колонке своя ручка у левого края колонки, и его действия не выходят из колонки. В «Добавить ›» Раскладка — подменю шести вариантов; у Блока в колонке есть пункт «Раскладка ›» (вариант её Раскладки), а у Раскладки, выделенной целиком, «Формат ›» — эти шесть вариантов с отметкой текущего.
  • Обёртка (OLDSUI-77, раздел «Обёртка»). Обёртка — тоже контейнер Блоков: у Блока в ней своя ручка у левого края Обёртки, и его действия из неё не выходят. Пункты «Тон ›» и «Снять обёртку» есть у Блока прямо в Обёртке и у самой Обёртки, выделенной целиком (Backspace на Блоке под ней); у неё же «Вверх», «Вниз», «Дублировать» и «Удалить» берут Обёртку вместе с содержимым, а «Формат ›» недоступен — типа Блока у неё нет.
  • Место. Ручка лежит в DOM Редактора и едет вместе с текстом в любом контейнере прокрутки, в Dialog и Drawer; липкая Панель и поповеры её перекрывают. Позиция пересчитывается на каждой правке и при смене размера.

Строки каталога: editor.blockActions (имя ручки, меню и строки справочника), blockAdd, blockFormat, blockMoveUp, blockMoveDown, blockDuplicate, blockDelete. Значки — MoreVerticalIcon, AddIcon, ParagraphIcon, ArrowUpIcon, ArrowDownIcon, DuplicateIcon, DeleteIcon из @olddevs/ui/icons.

Клавиатура: команды над Блоками

Команды над Блоками — как в Notion и Google Docs. Своей разметки у них нет, поэтому они есть при любом наборе tools (кроме очистки стилей — она идёт с инструментом clearFormatting). Каждая команда — одна правка: Ctrl/⌘+Z отменяет её целиком. В readOnly и disabled правящие команды Документ не меняют и отдают сочетание браузеру; ⌘A работает и там — выделение Документ не меняет.

Сочетание (Mac / остальные)Действие
⌘⇧D / Ctrl+Shift+DДублировать Блок: копия встаёт сразу под ним, каретка — в копию на то же место
⌥⇧↑ / Alt+Shift+↑Переставить Блок вверх
⌥⇧↓ / Alt+Shift+↓Переставить Блок вниз
⌘/ / Ctrl+/Открыть меню ручки Блока (раздел «Ручка Блока «⋮»»)
⌘A / Ctrl+AПервое нажатие — текст текущего Блока, второе — весь Документ. Если текст Блока уже выделен или Блок пуст, первое нажатие сразу выделяет Документ
⌘] и ⌘[ / Ctrl+] и [Сдвинуть пункт списка или чек-листа вправо и влево — то же, что Tab и Shift+Tab
⌘\ / Ctrl+\Очистить стили выделения — то же, что пункт «Очистить стили» меню «…» (clearFormatting)
  • Что такое Блок. Верхнеуровневый Блок Документа — абзац, заголовок, цитата, блок кода, картинка, таблица целиком, разделитель, список целиком. В списке Блок — пункт: копия встаёт в тот же список, а перестановка идёт среди соседних пунктов и из списка не выводит. Выделение через несколько Блоков дублируется и переставляется целиком.
  • На краю Документа или списка перестановка ничего не делает: соседа нет. Сочетание при этом поглощается — иначе на Mac оно растягивало бы выделение.
  • В таблице ⌥⇧↑/↓ не действует: выделение внутри одной таблицы не переставляет ни её, ни строки, а Alt+↓ по-прежнему добавляет строку (раздел «Таблицы»). Таблица переставляется, когда выделение начинается или кончается вне её. Дублирование из ячейки копирует таблицу целиком.
  • ⌘] и ⌘[ вне списка ничего не делают и отдают сочетание браузеру (в Chrome на Mac это «Вперёд» и «Назад»): отступа абзаца в Схеме нет. В пункте списка сочетание поглощается, даже если сдвигать некуда.
  • Почему не сочетания образца. ⌘⇧↑/↓ на Mac — «выделить до начала или конца документа», ⌘⇧A в Chrome — поиск по вкладкам: забирать их у системы нельзя. Перестановка — ⌥⇧↑/↓, как в VS Code.
  • Пункт «Очистить стили» меню «…» подписан ⌘\ (Ctrl+\ у остальных).

Ссылки

Инструмент link: кнопка «Ссылка» в Панели и Ctrl/⌘+K открывают поповер с полем адреса у выделенного текста.

  • Enter (или «Применить») делает выделение ссылкой и возвращает фокус в документ. Без выделения адрес вставляется текстом ссылки. Каретка в существующей ссылке — поле показывает её адрес, Enter меняет его на весь текст ссылки, а кнопка «Убрать ссылку» снимает ссылку, оставляя текст. Пустое поле и Enter — то же снятие.
  • Escape отменяет и возвращает фокус в документ на прежнее выделение. Внутри Dialog и Drawer Escape закрывает только поповер: Popover и окна кита — один стек слоёв Radix (ADR-0041). Щелчок мимо закрывает поповер, а фокус остаётся там, куда щёлкнули.
  • Адрес пишется как в строке браузера: example.com становится https://example.com, team@example.com — mailto:team@example.com.
  • Разрешены только абсолютные адреса со схемой http, https или mailto. Поле не примет javascript:, data:, ftp: и относительный адрес (/docs, #part) — под полем появится ошибка, ссылка не ставится. Относительных нет намеренно: Документ уезжает со страницы, где его написали, — в RichTextView на другом маршруте, в письмо, в Markdown, — и относительный адрес там значит другое или ничего, а якорям в Документе не на что указывать. Ослабить правило позже можно без ломки сохранённого, ужесточить — уже нет.
  • Автоссылка при наборе: адрес в тексте (example.com, https://…, адрес почты) становится ссылкой, когда слово закончено пробелом. Вставка адреса поверх выделения делает выделение ссылкой. Обе дороги проходят ту же проверку схемы.
  • В Редакторе ссылка по щелчку не открывается — щелчок ставит каретку, как в любом тексте. Открывает ссылки RichTextView и readOnly-поле, как обычные <a>.
  • На выходе каждая ссылка — <a href="…" rel="noopener noreferrer">. target, class, title и чужой rel из входящего HTML отбрасываются: новая вкладка — решение страницы показа, а не данных.

Инструменты и пресеты

preset="full" (по умолчанию) — всё, что есть в выпуске. preset="basic" — для комментариев и коротких описаний. tools — точный список, сильнее пресета; в нём вперемешку с именами кита стоят описания своих Инструментов (раздел «Свой Инструмент»). Тип элемента tools — Tool (имя кита или CustomTool), тип имени кита — ToolName; оба экспортируются из @olddevs/ui/editor.

Пресеты экспортируются и массивами имён — RICH_TEXT_PRESETS.full и RICH_TEXT_PRESETS.basic, чтобы собирать из них свой набор: tools={[...RICH_TEXT_PRESETS.full, mergeTagTool]}. Массивы заморожены. Состав пресета — «то, что кит считает полным набором», а не замороженный список: новый встроенный Инструмент приходит в пресет minor-версией. Кому нужен стабильный состав, передаёт точный список имён.

Выключенный инструмент убирает и кнопку, и расширение Схемы. Его Блок или Метку не получить ни сочетанием клавиш, ни markdown-шорткатом, ни вставкой, а из value они приходят без обёртки — текст остаётся.

ToolКнопкаРазметкаСочетаниеMarkdown при набореbasic
heading«¶»: Заголовок 2–4h2, h3, h4Ctrl/⌘+Alt+2…4## , ### , #### —
heading1«¶»: добавляет Заголовок 1h1Ctrl/⌘+Alt+1# —
historyОтменить, Повторить—Ctrl/⌘+Z, +Shift+Z—да
boldЖирныйstrongCtrl/⌘+B**текст**да
italicКурсивemCtrl/⌘+I*текст*да
underline«…»: ПодчёркиваниеuCtrl/⌘+U——
strikeЗачёркнутыйsCtrl/⌘+Shift+S~~текст~~да
code«…»: Строчный кодcodeCtrl/⌘+E`текст`да
superscript«…»: Верхний индексsupCtrl/⌘+.——
subscript«…»: Нижний индексsubCtrl/⌘+,——
clearFormatting«…»: Очистить стили— (снимает метки)Ctrl/⌘+\——
linkСсылка (поповер адреса)a[href]Ctrl/⌘+K[текст](адрес) при вставкеда
color«…»: Цвет текста › — именаspan[data-color], span[style]———
highlight«…»: Маркер › — именаmark[data-color], mark[style]———
alignВыравнивание ▾ (меню)p, h2–h4 с data-alignCtrl/⌘+Shift+L, +E, +R——
bulletListСписки ▾ и «¶»: Маркированныйul, liCtrl/⌘+Shift+8- или * да
orderedListСписки ▾ и «¶»: Нумерованныйol, liCtrl/⌘+Shift+71. да
taskListСписки ▾ и «¶»: Чек-листul[data-type=taskList] и пунктыCtrl/⌘+Shift+9[ ] или [x] —
blockquote«¶»: ЦитатаblockquoteCtrl/⌘+Shift+B> да
codeBlock«…»: Блок кодаpre, code class="language-*"Ctrl/⌘+Alt+C```—
horizontalRule«+»: Разделительhr—---—
tableТаблица ▾ (меню)table, tbody, tr, th, tdTab, Shift+Tab, Alt+↓ в таблице——
imageКартинка (поповер адреса)figure, img, figcaption—![alt](адрес) при вставке—
emojiЭмодзи (поповер с сеткой)— (обычный текст)———
sourceHTML (переключатель режима)— (правит HTML Документа)———
layout«+»: Раскладкаdiv с data-block и data-layoutTab, Shift+Tab, стрелки у края колонки——
wrapper«+»: Обёрткаdiv[data-block=wrapper]Enter в пустом последнем абзаце — выход——
insertMenu«+» Меню вставки— (Блоки других инструментов)—/—

Абзац есть всегда, мягкий перенос строки — Shift+Enter. insertMenu и emoji, как и clearFormatting, своей разметки не имеют: insertMenu открывает дорогу к чужим Блокам, а выключают его там, где / — обычный символ (пути, «и/или»); в basic его нет по той же причине. emoji вставляет обычный текст — Схема, санитайзер и Markdown от него не меняются (раздел «Эмодзи»); в basic его нет: в комментарии системная палитра ОС ближе, а кнопка занимала бы место у короткого ряда. source — HTML-режим (раздел «HTML-режим»): своей разметки у него тоже нет, правка HTML проходит ту же Схему, что value, и обойти набор инструментов им нельзя; в basic его нет — в комментарии кнопка «HTML» без явного запроса экрана лишняя. Заголовок первого уровня — heading1 (надстройка над heading): в full он есть, в basic нет. С ним уровни Блока — 1–4: Ctrl/⌘+Alt+1 и # с пробелом дают H1, <h1> из value, defaultValue и вставки остаётся H1, меню «¶» на Панели предлагает «Заголовок 1». Без него — прежнее поведение: # при наборе остаётся текстом, Ctrl/⌘+Alt+1 ничего не делает, <h1> во входящем HTML становится абзацем. Выключайте heading1 на экране, где заголовок страницы уже стоит над Редактором: второй H1 ломает структуру страницы. Без инструмента heading H1 нет, даже если heading1 передан. Раньше «H1 в Документе нет» было решением кита (заголовок страницы стоит вне Редактора); теперь это решение экрана — ADR-0053 пересматривает прежнее, ссылка на него — из ADR-0050.

Имя приходит в тип Tool вместе с самим инструментом, не раньше: расширение входного типа потребителя не ломает, а имя без реализации компилировалось бы и ничего не давало. Так пришли link — сразу и в пресет basic — и color, highlight, align, table, image, emoji, layout, wrapper, source; зарезервированных имён сейчас нет. Неизвестные имена в tools (из JS без типов) отбрасываются молча.

preset и tools читаются при создании движка: Схему живого ProseMirror не поменять. Чтобы сменить набор у смонтированного поля, перемонтируйте его ключом (key) — история правок при этом теряется.

Свой Инструмент

Свой Инструмент — описание одной возможности Редактора: его Блоки и Метки, правила Схемы данными и Часть для движка (расширения Tiptap). Он передаётся в tools вперемешку с именами кита и подчиняется тем же правилам: не переданный Инструмент убирает свои Блоки и Метки из Схемы — из value, вставки и команд они не проходят, Блок разворачивается в своё содержимое, атом пропадает. Решение — ADR-0058, разделы 1 и 2.

import { Node } from "@tiptap/core";
import { RICH_TEXT_PRESETS, RichTextEditor, type CustomTool } from "@olddevs/ui/editor";

// Часть для движка: только поведение. Группу, разбор HTML и атрибуты ставит кит.
const MergeTag = Node.create({
  name: "acme_mergeTag",
  atom: true,
  addAttributes: () => ({ acme_key: { default: null } }),
  renderText: ({ node }) => `{{${node.attrs.acme_key ?? ""}}}`,
});

export const mergeTagTool: CustomTool = {
  name: "acme_mergeTag",
  engine: [MergeTag],
  blocks: {
    acme_mergeTag: {
      kind: "inline",
      attributes: { acme_key: { kind: "pattern", pattern: "[a-z_]+" } },
    },
  },
};

<RichTextEditor
  tools={[...RICH_TEXT_PRESETS.full, mergeTagTool]}
  value={html}
  onChange={setHtml}
/>;

Описание (CustomTool). Обязательны имя, Часть для движка и правила Схемы для каждого Node и Mark, которые она определяет. Остальное — по умолчанию: обычный Блок без Признаков, без своих кнопок и пунктов меню.

ПолеЧто это
nameИмя Инструмента с Префиксом потребителя, уникальное в tools
engineЧасть для движка — массив расширений Tiptap (Node, Mark, Extension, плагины, React-вид узла; Node и Mark — на верхнем уровне) или загрузчик его (ниже)
blocksПравила Схемы Блоков — по имени Node (CustomBlock)
marksПравила Схемы Меток — по имени Mark (CustomMark)
buttonsКнопки Панели — раздел «Кнопка своего Инструмента» (CustomToolButton)
insertItemsПункты Меню вставки (CustomInsertItem) — раздел «Пункты вставки и Действия над Блоком»
blockActionsДействия над Блоком в меню Ручки Блока (CustomBlockAction) — тот же раздел
calloutsКарточки у текста (CustomCallout) — раздел «Карточка у текста»

Префикс потребителя. Имена Инструмента, его Блоков, Меток и атрибутов начинаются с префикса через подчёркивание: acme_mergeTag, атрибут acme_size. В именах кита подчёркивания не бывает, поэтому своё и встроенное не пересекаются ни в наборе, ни в сохранённом Документе. Имена атрибутов — строчными: в HTML регистр имени атрибута теряется. Встроенный Инструмент не подменяется: выключите его в tools и поставьте свой под своим именем.

HTML-соглашение. Его пишет и читает кит, Часть для движка задаёт только поведение:

  • блочный Блок — <div data-block="acme_card">…</div>, строчный атом — <span data-block="acme_mergeTag"></span>;
  • своя Метка — <span data-mark="acme_comment">…</span>;
  • свой атрибут acme_size — data-acme_size; других атрибутов у своего Блока и Метки нет.

Кит дописывает Node и Mark Части для движка: группу (block, inline или верхний уровень) и строчность — по виду Блока, разбор HTML — только по этому соглашению (тег кита — p, table, img — свой Блок занять не может), атрибуты из правил — разбор и вывод data-* с проверкой значения. Свой renderHTML можно оставить (например, чтобы положить текст в атом), но он обязан давать тот же тег и data-block. Свои атрибуты Node объявлять не обязательно: кит добавит их из правил.

Правила Схемы — данные, без функций. Их проверяет код кита, и ослабить проверку Частью для движка нельзя: значение вне правил не попадает в HTML Документа ни из value, ни из вставки, ни из команды: при разборе атрибут получает значение по умолчанию, при выводе не пишется.

Поле CustomBlockЗначение
kind"block" — блочный (div) или "inline" — строчный (span). Обязательно
place"anywhere" (по умолчанию) — везде, где стоит Блок; "top" — только верхний уровень Документа, как Раскладка
attributesСвои атрибуты: имя → вид значения (CustomAttribute)
containerПризнак «контейнер»: Блоки внутри — со своей Ручкой Блока, как у Обёртки
turnIntoПризнак «можно превратить в»: «Формат ›» Ручки Блока доступен и для этого Блока
plainAtomПризнак «атом без меню форматирования»: над выделенным Блоком нет Меню выделения и плавающей Панели
islandОстров Блока в RichTextView — клиентский компонент из модуля с "use client" (CustomIslandProps); Редактор поле не читает

У CustomMark — только attributes. Вид значения атрибута (CustomAttribute):

  • { kind: "list", values: ["s", "m", "l"] } — одно из значений списка;
  • { kind: "pattern", pattern: "[0-9]{3}" } — регулярное выражение строкой на всё значение (^…$ кит ставит сам);
  • { kind: "url" } — адрес по политике протоколов кита, как у ссылки: абсолютный http, https или mailto.

HTML внутри атрибута не бывает: значение с < не проходит ни по какому правилу, а содержимое нередактируемого Блока хранится разметкой Документа в его детях.

Признаки Блока по умолчанию выключены: обычный Блок — не контейнер, без «Превратить в», с Меню выделения. Ручка Блока у него есть, как у любого Блока: «Добавить ›», «Формат ›» (недоступен), «Вверх», «Вниз», «Дублировать», «Удалить». Кит переносит Признаки в spec узла сам; их словарь ведёт кит, а не Tiptap.

Проверка при сборке набора — ошибка поля. Набор проверяется один раз, при создании движка; с загрузчиками — когда они отработали, по тому, что они отдали. Ошибка, если:

  • имя Инструмента, Блока, Метки или атрибута без Префикса потребителя;
  • два Инструмента с одним именем или одно имя Блока или Метки у двух;
  • Часть для движка расходится с правилами: Node или Mark без правила, правило без Node или Mark на верхнем уровне engine, атрибут узла без правила, renderHTML мимо соглашения, Схема, которую движок не собрал;
  • у кнопки нет id или значка, два id в одном Инструменте совпали, или не задано ровно одно из run и popover (у пункта вставки и Действия над Блоком — тоже).

При ошибке движок не создаётся: под рамкой поля — строка role="alert" «Редактор не запустился. Документ не изменён.» (editor.toolsFailed), скрытое поле формы и getHTML() отдают входящее значение, onChange молчит, ref.current.editor — null. Причина — в console.error в режиме разработки, с именем Инструмента. Так потеря данных видна до первого сохранения, а не на сервере. Неизвестная строка в tools по-прежнему отбрасывается молча: ошибка относится только к описаниям своих Инструментов.

Кнопки, пункты Меню вставки, Действия над Блоком и Карточки у текста описаны в разделах ниже; сообщить автору об ошибке Инструмент может командой showNotice (раздел «Сообщение поля»). Всё API расширения одной картой и граница его стабильности (что подчиняется semver, что считается ломающим) — на странице API расширения Редактора.

Загрузчик Части для движка

Вместо массива engine своего Инструмента можно передать функцию с динамическим импортом — она отдаёт промис того же массива (ADR-0058, п. 1.3 и 11.3–11.6). Так модуль с описанием можно импортировать и на сервере (правила Схемы — данные), а Tiptap и React Части для движка уходят в отдельный чанк:

// acme-merge-tag.ts — без Tiptap в графе: его тянет только чанк Части для движка.
export const mergeTagTool: CustomTool = {
  name: "acme_mergeTag",
  engine: () => import("./acme-merge-tag-engine.js").then((module) => module.engine),
  blocks: {
    acme_mergeTag: {
      kind: "inline",
      attributes: { acme_key: { kind: "pattern", pattern: "[a-z_]+" } },
    },
  },
};
  • Готовность ждёт загрузчики. Движок создаётся, когда отработали загрузчики всех Инструментов набора: Схема фиксируется при создании, и Блоки из value без своей Части для движка потерялись бы при разборе. До того методы Хэндла тихие, ref.current.editor — null, onReady не вызван (раздел «События»).

  • Загрузка стартует с первого рендера в браузере, ещё до монтажа, а не в эффекте; на сервере загрузчик не зовётся. Промис кэшируется на самой функции загрузчика: второй Редактор на странице с тем же Инструментом ничего не ждёт и загрузчик не зовёт. Поэтому загрузчик — одна функция на модуль, а не новая стрелка на каждый рендер.

  • Сбой загрузки (сеть, устаревший чанк после выкладки) — ошибка поля «Редактор не запустился. Документ не изменён.» и кнопка «Повторить» (editor.retry). Она зовёт заново только упавшие загрузчики; удачные остаются в кэше. Документ всё это время виден Скелетом, а старта без Инструмента нет: его Блоки пропали бы из Документа. onReady при сбое не вызывается, причина — в console.error в режиме разработки.

  • Предзагрузки кит не публикует. Чтобы чанк пришёл вместе со страницей, а не после её скрипта, добавьте на него modulepreload сами. Адрес чанка с хешем — из манифеста сборщика (в Vite — build.manifest):

    <link rel="modulepreload" href="/assets/acme-merge-tag-engine-3f9a1c.js" />

    В React 19 тот же тег ставит preloadModule(url) из react-dom. Без предзагрузки чанк начнёт грузиться с первого рендера Редактора — это время закрывает Скелет.

Скелет

Скелет (ADR-0058, п. 11.4) есть у каждого Редактора, с загрузчиками и без: до Готовности и при сбое загрузчика поле рисует рамку, место фиксированной Панели и Документ статичным HTML — тем же классом и ритмом, что Документ движка, без ввода и без роли textbox. Движок встаёт на его место без прыжка высоты.

  • Документ Скелета — value, очищенный Схемой, а не строка как есть: кит разбирает её в инертном документе браузера (скрипты и обработчики там не исполняются) по Схеме встроенных Инструментов набора и своих, чья Часть для движка уже есть, и выгружает обратно в HTML. Что Схема не знает — <script>, on*, javascript:, чужие теги и атрибуты, — в страницу не попадает и до Готовности (раздел «Схема и безопасность»). Блок своего Инструмента, чей загрузчик ещё не отработал, виден текстом: его обёртку Схема снимает, а движок с пришедшей Частью разбирает value заново.
  • Блок кода и таблица в full тоже ленивые (ADR-0058, п. 11.1): их нет в basic, а их Часть для движка тяжелее 10 кБ gzip, поэтому она приходит отдельным чанком тем же загрузчиком, что у своего Инструмента, — вместе с меню «Таблица», окном «Вставить таблицу» и шапкой блока кода. До прихода чанков Редактор с ними — Скелет, onReady ждёт оба. Их Блоки в Скелете видны своей формой — блок кода с шапкой, таблица ячейками, — и поле не прыгает с приходом чанков. basic чанки не грузит и готов сразу. Чтобы чанки пришли вместе со страницей, поставьте на них modulepreload, как на чанк своего Инструмента. Цена «старта» и «полного» — таблица «Сколько стоит импорт» в getting-started.
  • На сервере и до гидратации Скелет — только рамка и место Панели, без Документа: разбор Схемой идёт в DOM браузера. Документ появляется после монтирования, поэтому на SSR-странице высота поля меняется один раз, как до Скелета. В клиентском рендере Документ Скелета есть с первого кадра.

Вид Скелета не входит в контракт.

Текст атома

Одно правило для встроенных и своих атомов (ADR-0058, раздел 4): Текст атома — то, что даёт renderText Части для движка; листовой атом без renderText — пустая строка, атом с содержимым внутри (картинка с подписью) — текст своих детей. Из него кит строит всё, что выдаёт текст:

  • Вес в maxLength равен длине Текста атома, поэтому счётчик совпадает с getText() без разделителей Блоков. Для встроенных: разделитель весит 0, мягкий перенос — 1, картинка — длину подписи; Merge Tag из примера выше — длину {{ключа}}. Отдельного поля веса нет: оно разошлось бы с текстом. Серверного счётчика у кита нет — вес зависит от Части для движка.
  • getText(), getSelectedText(), текст при копировании и getMarkdown() отдают один и тот же Текст атома; атом попадает в выделенный текст только целиком. Атом без renderMarkdown Markdown выгружает его Текстом атома, а пропадает только атом без текста; свой renderMarkdown у Части для движка сильнее.
  • Пустота — правило кита, без поля описания. Любой атом, свой или встроенный, — содержимое для required, isEmpty() и getHTML(): вставка видео без подписи проходит проверку обязательности. Пустой не-атомарный Блок (абзац без текста) содержимым не считается.
  • Вставка из буфера за пределом maxLength срезает хвост по символам, а не по позициям. Атом не режется: не поместившийся уходит целиком вместе со всем, что вставлено после него; если предел всё ещё превышен, вставка отвергается целиком.

Вне Редактора: сервер, Markdown и Просмотр

Тот же массив tools понимают серверная очистка и Просмотр (ADR-0058, п. 2.3 и раздел 3).

  • Сервер передаёт очистке тот же массив tools: sanitizeRichText(html, DOMPurify, tools) пропускает свои Блоки, атомы и Метки по их правилам и срезает всё прочее, richTextSchema(tools) отдаёт эти правила данными. Имена Инструментов кита белый список не сужают, без третьего аргумента очистка прежняя. Оба имени есть и в @olddevs/ui/editor, и в лёгком @olddevs/ui/editor/schema; в графе лёгкого входа по-прежнему нет ни Tiptap, ни React, а описание своего Инструмента — данные, которые сервер читает без Части для движка. Подробно — RichTextView, «Свои Инструменты: третий аргумент».
  • Markdown. Свои правила — в Части для движка, как у любого расширения Tiptap: renderMarkdown, а для разбора — markdownTokenizer и parseMarkdown; с ними Блок проходит круг getMarkdown() → setMarkdown(). Без них getMarkdown() не теряет текст: Блок выгружается своим содержимым (контейнер — своими Блоками), атом — Текстом атома из renderText (экранированным, как любой текст), Метка — своим текстом; пропадает только атом без текста. Обратно такой Блок из Markdown не создаётся — его текст встаёт абзацами. В режиме разработки — одно предупреждение console.warn на Инструмент, когда это умолчание впервые сработало.
  • RichTextView показывает свой Блок очищенным HTML с вертикальным ритмом абзаца — и больше ничего; вид — ваш CSS по data-block и токенам кита (RichTextView, «Свой Блок»). Оживить Блок в показе можно Островом — полем island у Блока; RichTextView получает тот же tools (RichTextView, «Остров своего Блока»).

Кнопка своего Инструмента

Свой Инструмент описывает кнопку данными в поле buttons, а рисует её кит: роли, одну Tab-остановку Панели и стрелки по ней, aria-pressed, подсказку с Kbd и поповер с рамкой, слоем и возвратом фокуса. Произвольный React допускается только в содержимом поповера. Решение — ADR-0058, разделы 5 и 6.

import { RichTextEditor, type CustomTool } from "@olddevs/ui/editor";
import { SparkleIcon } from "@olddevs/ui/icons";

export const stampTool: CustomTool = {
  name: "acme_stamp",
  engine: [], // кнопке своей Схемы и Части для движка не нужно
  buttons: [
    {
      id: "stamp",
      label: ({ locale }) => (locale.startsWith("ru") ? "Штамп" : "Stamp"),
      icon: SparkleIcon,
      can: (editor) => editor.state.selection.$to.parent.inlineContent,
      run: (editor) => void editor.chain().focus().insertContent("★").run(),
      shortcut: ["mod", "shift", "Y"],
    },
  ],
};
ПолеЧто это
idКлюч кнопки, уникальный в своём Инструменте. С id кита и чужих Инструментов не сталкивается: кит добавляет к нему имя Инструмента
labelПодпись — строка или функция от { locale }. Имя кнопки для скринридера, текст подсказки и имя поповера
iconКомпонент с контрактом IconProps из словаря кита или свой. Обязателен: имён из словаря и запасного значка нет
canДоступна ли кнопка сейчас. Недоступная — aria-disabled, остаётся в обходе стрелками, щелчок и сочетание ничего не делают
isActiveЕсть — кнопка-переключатель с aria-pressed; нет — разовое действие без aria-pressed
isVisibleПредикат видимости по контексту: кнопка скрыта, пока он возвращает false. Нет — видна всегда
selectionMenutrue — кнопка стоит ещё и в Меню выделения (toolbar="bubble")
shortcutСочетание: mod, alt, shift и клавиша — ["mod", "shift", "Y"]
run(editor) => void — щелчок и сочетание
popover({ editor, close }) => ReactNode — поповер вместо действия. Ровно одно из run и popover
  • Место. Свои кнопки стоят на Панели одной группой перед «Отменить» и «Повторить», после групп кита, в порядке tools и порядке массива buttons. Фиксированная и плавающая Панель показывают один набор. Порядок кнопок кита задаёт кит; спрятать встроенную можно только выключив Инструмент, а короткую Панель получают, сужая tools. Выключенный Инструмент уносит и кнопки, и сочетание.
  • Состояние. can, isActive и isVisible читаются на каждой транзакции, как у кнопок кита, поэтому берут состояние Документа: editor.isActive("acme_pin"). Нередактируемое поле кит выключает сам.
  • Поповер. Рамку держит кит: слой Popover, якорь у кнопки, стек Escape (внутри Dialog Escape закрывает поповер, а не окно), имя рамки — подпись кнопки. Содержимое монтируется заново на каждое открытие. close закрывает поповер тем же путём, что Escape. Закрытие возвращает фокус и выделение в Документ, а не на кнопку; щелчок мимо фокус не трогает. Вызванный сочетанием поповер встаёт у каретки, а не у кнопки: кнопки на экране может и не быть (toolbar="none").
  • Сочетание — единственный источник. Кит сам привязывает клавишу к run (или открывает поповер), пишет её в подсказку с Kbd, в aria-keyshortcuts кнопки и в справочник сочетаний: свои строки стоят там отдельной группой без заголовка после групп кита, в порядке tools. Недоступная кнопка клавишу не берёт, и она идёт дальше, в Документ. Клавиши, которые вешает Часть для движка (addKeyboardShortcuts), в справочник не попадают: сочетание кнопки задавайте полем shortcut, а не расширением.
  • Подпись. Функция получает { locale } — локаль UiProvider — и перерисовывается при её смене; в одноязычном приложении хватает строки. Переопределение подписей — опциями фабрики Инструмента (например, поле labels; рецепт — API расширения, «Подписи через опции фабрики»), каталог UiProvider для чужих Инструментов не расширяется. Пустая подпись: в разработке — console.error с именем Инструмента и кнопки, а в имя кнопки, подсказку и имя поповера встаёт имя Инструмента.
  • Меню-виджетов (радио, подменю) своим Инструментам нет: для выбора из списка хватает поповера.

Пункты вставки и Действия над Блоком

Свой Инструмент добавляет пункт в Меню вставки и Действие в меню Ручки Блока описанием, без правки меню: кит рисует, потребитель описывает (ADR-0058, разделы 5 и 6). Выключенный Инструмент убирает свои пункты и Действия отовсюду.

import { ColumnLayoutIcon } from "@olddevs/ui/icons";

export const calloutTool: CustomTool = {
  name: "acme_callout",
  engine: [Callout],
  blocks: { acme_callout: { kind: "block", container: true } },
  insertItems: [
    {
      section: "insert",
      // Строка — для одного языка; функция получает { locale } из UiProvider.
      label: ({ locale }) => (locale.startsWith("ru") ? LABELS.ru : LABELS.en),
      icon: ColumnLayoutIcon,
      aliases: ["note", "tip"],
      shortcut: ["mod", "alt", "N"],
      run: (chain) =>
        chain.insertContent({ type: "acme_callout", content: [{ type: "paragraph" }] }),
    },
  ],
  blockActions: [
    {
      label: "Unwrap callout",
      icon: ColumnLayoutIcon,
      blocks: ["acme_callout"],
      isVisible: (editor, pos) => editor.state.doc.nodeAt(pos)?.type.name === "acme_callout",
      run: (editor, pos) => unwrapCallout(editor, pos),
    },
  ],
};

Пункт вставки (CustomInsertItem).

ПолеЧто это
section"insert" («Добавить» — новый Блок после текущего) или "turnInto" («Превратить в» — сменить текущий Блок с текстом). Обязательно
labelПодпись: строка или функция от { locale } (локаль UiProvider), как у кнопки. Обязательно
iconКомпонент с контрактом IconProps из @olddevs/ui/icons или свой. Обязательно: запасного значка нет
aliasesМассив строк на любом языке — слова для поиска сверх подписи
shortcutСочетание в нотации Панели: ["mod", "alt", "N"]. Единственный источник: кит вешает клавишу, пишет её в подсказку пункта и в справочник
run(chain, editor) => chain — команда над цепочкой: вернуть цепочку с командой Блока, .run() зовёт кит
popover({ editor, close }) => ReactNode — поповер у каретки вместо команды. Ровно одно из run и popover
  • Где пункт. Он сам попадает в «/», «+» Панели, «Добавить ›» и «Формат ›» Ручки Блока: раздел turnInto — и в «Формат ›», и в «¶»; в «Добавить ›» стоят пункты обоих разделов (новый пустой Блок ниже, как у встроенных). Свои пункты встают в конец своего раздела, в порядке tools; порядок внутри Инструмента — порядок массива. Пункта без включённого insertMenu нет — как у встроенных.
  • Одна правка, одна отмена. Раздел решает, что делается с Документом, а run — какой Блок: в insert кит готовит пустой абзац (текущий пустой, иначе новый за Блоком), в turnInto — снимает со Блока обёртки (список, цитату). Подготовка и run идут одной транзакцией.
  • Поиск в «/» идёт по подписи в текущей локали, по подписи, вызванной с en-US, и по aliases — как у встроенных. Подпись-функция зовётся при отрисовке: смена локали UiProvider перерисовывает пункт.
  • Подсказка в строке — Kbd сочетания; клавиатура меню — как у встроенных (стрелки, Enter, Escape, первая буква). Сочетание срабатывает у каретки так же, как выбор пункта в «/».
  • Пустая подпись — в разработке console.error с именем Инструмента и записи, а в меню вместо неё стоит имя Инструмента.
  • Поповер (popover) — когда Блоку нужен выбор до вставки (символ, шаблон, адрес). Рамка та же, что у кнопки: Popover кита в стеке Escape (внутри Dialog Escape закрывает поповер, а не окно), имя рамки — подпись пункта, закрытие возвращает фокус и выделение в Документ. Выбор в «/», «+», «Добавить ›» или «Формат ›» закрывает меню и открывает поповер у каретки: из «/» — там, где стоял «/», набранная «/команда» убирается, как у обычного пункта. Сочетание пункта открывает тот же поповер у каретки. Документ кит не готовит — ни пустого абзаца, ни снятых обёрток: Блок ставит содержимое через editor, одной транзакцией — одна отмена.

Действие над Блоком (CustomBlockAction).

ПолеЧто это
labelПодпись — строка или функция от { locale }. Обязательно
iconКомпонент с контрактом IconProps. Обязательно
blocksИмена Блоков, для которых Действие показывается: имя Блока Ручки или контейнера, в котором он стоит прямо. Обязательно, непустой массив
run(editor, pos) => void — pos — позиция Блока Ручки в Документе. Правка — одна транзакция: отмена одна
popover({ editor, close, pos }) => ReactNode — поповер у Блока вместо действия; pos — позиция его Блока. Ровно одно из run и popover
isVisible(editor, pos) => boolean — предикат видимости по контексту; нет — Действие видно у всех своих blocks
shortcutСочетание в нотации Панели: срабатывает над Блоком под кареткой и только там, где Действие видно в меню Ручки; на другом Блоке клавиша идёт в Документ

Свои Действия стоят в меню Ручки Блока своей секцией перед «Удалить», в порядке tools. Действие с popover открывает поповер у Блока — рамка, Escape и возврат фокуса те же, что у кнопки, имя рамки — подпись Действия: выбор в меню Ручки — у её Блока, сочетание — у Блока под кареткой. pos едет за правками Документа, пока поповер открыт. Сочетание — в подсказке пункта (как у встроенных, только когда оно делает то же) и в справочнике сочетаний: строки Инструмента — отдельная группа без заголовка после групп кита.

Проверка набора добавляет ошибки поля для записей: insertItems/blockActions не массив или запись не объект; нет подписи или значка; не ровно одно из run и popover; раздел не insert/turnInto; aliases не массив строк; blocks пуст; isVisible не функция; сочетание короче двух клавиш (модификатор и клавиша).

Карточка у текста

Четвёртый вид UI-записи (после кнопки, пункта вставки и Действия над Блоком): небольшая рамка под диапазоном текста, у которого стоит каретка, — например, карточка комментария под Меткой-замечанием. Кит держит рамку, Инструмент описывает диапазон и тело (ADR-0058, раздел 7, п. 6–11).

import { Mark } from "@tiptap/core";
import { markRange, type CustomTool } from "@olddevs/ui/editor";

const Comment = Mark.create({ name: "acme_comment" });

export const commentTool: CustomTool = {
  name: "acme_comment",
  engine: [Comment],
  marks: { acme_comment: {} },
  callouts: [
    {
      // Строка или функция от { locale }: имя рамки и часть фразы для скринридера.
      label: ({ locale }) => (locale.startsWith("ru") ? LABELS.ru : "Comment"),
      // Диапазон под кареткой; для Метки — готовый помощник кита.
      rangeAt: markRange("acme_comment"),
      render: ({ editor, range, close }) => (
        <div>
          <p>{editor.state.doc.textBetween(range.from, range.to)}</p>
          <button type="button" onClick={close}>
            Resolve
          </button>
        </div>
      ),
    },
  ],
};

Запись (CustomCallout).

ПолеЧто это
labelПодпись: строка или функция от { locale }. Доступное имя рамки (role="dialog") и часть фразы, которой кит сообщает скринридеру о появлении Карточки. Иконки у записи нет. Обязательно
rangeAt(state, pos) => { from, to } | null — диапазон, к которому привязана Карточка, если каретка (или место щелчка) в позиции pos в нём; нет — null. Обязательно
render({ editor, range, close }) => ReactNode — тело Карточки. range — диапазон из rangeAt на текущем состоянии (он едет за правкой), close прячет Карточку тем же путём, что Escape. Обязательно

markRange(markName) — готовый rangeAt для Метки: непрерывный отрезок текста с этой Меткой и теми же значениями её атрибутов вокруг pos. Края входят: каретка сразу за словом или перед ним — ещё в диапазоне (иначе щелчок по первой букве не открывал бы Карточку); между двумя отрезками берётся тот, что кончается здесь. Для Блока или любого другого диапазона пишите свой rangeAt — он читает только state и зовётся на каждой транзакции, поэтому должен быть быстрым и без побочных эффектов. Ответ вне Документа, с from > to или не числами кит отбрасывает; исключение в rangeAt не роняет поле, а пишет console.error в разработке. markRange есть и в лёгком входе @olddevs/ui/editor/schema: описание своего Инструмента с Карточкой — данные, которые читает и серверная очистка, и модулю описания не нужны ни Tiptap, ни React.

  • Когда открыта. Каретка (свёрнутое выделение) внутри диапазона или щелчок по нему. Наведением Карточка не открывается. Развёрнутое выделение Карточку закрывает: там работает Меню выделения. Диапазон пересчитывается на каждой транзакции: Карточка едет за ним при правке и закрывается, когда он исчез. Поле вне фокуса (кроме readOnly) Карточку не показывает, disabled и HTML-режим — тоже.
  • Одна Карточка. Открыта не больше одной: выигрывает самый узкий диапазон под кареткой, при равной ширине — запись, стоящая раньше, то есть Инструмент раньше в tools (внутри Инструмента — раньше в массиве).
  • Рамка у кита. Слой и стек Escape — как у Popover кита (ADR-0041: Escape закрывает Карточку, а не Dialog или Drawer вокруг поля), якорь под диапазоном, переворот над диапазоном у нижнего края окна, сдвиг от боковых краёв, вид как у поповера кита. Заголовка и кнопок-данных нет: тело — ваш React-узел; монтируется заново на каждое открытие.
  • Немодальна. Фокус остаётся в Документе, печать продолжается, Карточка едет за диапазоном. Alt+F10 ведёт в открытую Карточку (на первый фокусируемый элемент тела, иначе на саму рамку) — как при выделенной картинке ведёт в её панель. Escape возвращает каретку на место и прячет Карточку.
  • Спрятана. Escape и close прячут Карточку, пока каретка не покинет диапазон: правка и движение каретки внутри него её не возвращают. Щелчок по диапазону открывает её снова. Пока она спрятана, Alt+F10 ведёт в Панель, как без Карточки.
  • Скринридер. О появлении кит говорит фразой каталога editor.calloutShown («Comment card opened. Press Alt+F10 to move into it.», в русской локали — своя): в неё подставляются подпись записи и сочетание. Живой регион role="status" стоит в каждом Редакторе с Карточками и пуст, пока открытой нет.
  • readOnly. Карточки работают — читать комментарии можно и без правки; правки из render режет сам движок. Каретки у такого поля нет, поэтому Карточку открывает щелчок (место щелчка едет за правкой), закрывают щелчок мимо поля, Escape и выделение мышью. С клавиатуры в readOnly Карточку не открыть: Документ не принимает каретку, а Alt+F10 из него недостижим. При disabled Карточек нет.
  • Выключенный Инструмент убирает и свои Карточки. В RichTextView Карточек нет: он показывает очищенный HTML без движка; читать текст с Карточками — Редактор с readOnly.
  • Проверка набора добавляет ошибки поля: callouts не массив или запись не объект, нет подписи, rangeAt или render.

Блок кода

Инструмент codeBlock: пункт-флажок «Блок кода» меню «…» и пункт Меню вставки, Ctrl/⌘+Alt+C и ограда ``` в начале строки. У блока есть язык и подсветка синтаксиса — решение ADR-0051: lowlight с грамматиками highlight.js, только в этом входе (CodeView кита по-прежнему без подсветки, ADR-0039).

Языки. Обычный текст (без языка), Python, TypeScript, JavaScript, Go, HTML, JSON, YAML и Markdown — данные в одном модуле src/editor/codeLanguages.ts, имена в Документе — RICH_TEXT_SCHEMA.codeLanguages. В HTML язык — класс на <code>: <pre><code class="language-python">…</code></pre>, у блока без языка класса нет. Подсветка в Документ не пишется: токены span class="hljs-…" — декорации Редактора, в getHTML() их нет.

  • Псевдонимы приводятся к имени при загрузке value, вставке HTML, разборе Markdown и в ограде: py → python, ts → typescript, js → javascript, golang → go, htm → html, yml → yaml; регистр не важен. У JSON псевдонимов нет: ```jsonc и ```json5 несут комментарии и хвостовые запятые, которых грамматика JSON не знает, и открывают обычный текст. ```py и пробел открывают блок Python.
  • Неизвестный язык (```rust, class="language-rust") становится обычным текстом: блок остаётся, класс — нет.
  • Обычный текст не подсвечивается, даже если похож на код: язык не угадывается ни в Редакторе, ни на сервере, поэтому один и тот же блок выглядит одинаково в правке и при показе.
  • Markdown: ограда с языком ↔ блок с языком; getMarkdown() пишет имя (```python), а не псевдоним, с которым блок пришёл.

Шапка блока. Над кодом — тонкая полоса (около 28 px) с нижней линией из токена разделителя: слева язык, справа «Копировать». Обе части всегда на виду — и на десктопе, и на касании, без наведения. Код начинается сразу под полосой с обычным отступом блока, кнопки его не перекрывают: длинная первая строка прокручивается целиком. Полоса — хром блока: contenteditable="false", select-none, каретка в неё не встаёт, в выделение и в копию Документа она не попадает; в getHTML() её нет. Та же полоса — у блоков в RichTextView.

Выбор языка. Слева в шапке — кнопка с текущим языком и шевроном («Python», «Обычный текст»). Она открывает Menu кита со списком языков — радиогруппа, текущий отмечен; выбор меняет язык одним шагом отмены (Ctrl/⌘+Z возвращает прежний, код не трогает) и возвращает фокус в код на прежнее место. Имя кнопки — «Language: Python» / «Язык: Python» (messages.editor.codeLanguage), меню называется так же. Меню — RAC-оверлей кита и участвует в стеке слоёв (ADR-0041): внутри Dialog Escape закрывает меню, а не окно. В readOnly и disabled вместо кнопки — подпись с языком: менять нечего, а знать, на каком языке код, по-прежнему полезно. У блока без языка подписи в этих режимах нет — строка «Обычный текст» живёт в каталоге и нужна кнопке меню; так же ведёт себя RichTextView, у которого каталога нет.

Копирование. Справа в шапке — CopyButton кита (иконка, без подписи) с подсказкой «Copy code» / «Копировать код» (messages.editor.codeCopy), которая после нажатия на две секунды сменяется на «Copied». Имя кнопки и объявление результата в живой области — штатные для CopyButton: «Copy» → «Copied» / «Копировать» → «Скопировано» (messages.copyButton), отдельного имени для блока кода не заведено. В буфер уходит текст блока строкой (node.textContent): как набран, без токенов подсветки (они — декорации) и без разметки. Это правило ADR-0039: копируется строка, а не DOM. Кнопка есть в любом режиме — и при readOnly, и при disabled: править нельзя, скопировать можно. Отказ буфера (небезопасный контекст, запрет разрешений) кнопка показывает — «Could not copy», — значок «скопировано» не ставит.

Клавиатура. Обе кнопки шапки — вне обхода Tab (tabindex="-1"): иначе каждый блок кода добавлял бы две остановки между полем и следующим элементом формы. Вход — тот же Alt+F10: с кареткой в блоке кода он ведёт в кнопку языка этого блока (как на выделенной картинке — в её панель), а не в Панель. Путь по шапке:

  • Alt+F10 с кнопки языка — на «Копировать», ещё раз — в Панель; так сочетание обходит язык → копирование → Панель;
  • стрелка вправо с языка — на «Копировать», стрелка влево — обратно;
  • на кнопке языка Enter, Space или стрелка вниз открывают меню; стрелки, Home, End и первая буква — выбор; Enter — применить, Escape — закрыть меню;
  • на «Копировать» Enter или Space копируют;
  • Escape на любой из двух кнопок — фокус обратно в код, каретка там же, где была.

Кнопка языка объявляет aria-keyshortcuts="Alt+F10". В readOnly и disabled каретки нет, и Alt+F10 из блока недостижим, — поэтому там «Копировать» стоит в обычном порядке Tab (в правящемся Редакторе её tabindex — -1, в остальных режимах — штатный). Tab в блоке кода по-прежнему уводит фокус из поля: отступы в коде пишутся пробелами.

Тема подсветки — готовые темы highlight.js: GitHub Light в теме light, GitHub Dark в dark и dim. Устроена из двух половин. Цвета — переменные --rte-hl-* и --rte-code-block-* в styles.css на элементах с data-theme (и на :root без атрибута), поэтому блок кода берёт тему ближайшего предка с data-theme: Документ во вложенном data-theme="light" внутри тёмного приложения получает GitHub Light, тёмный остров на светлой странице — GitHub Dark. Правила .hljs-* — классы Tailwind на контейнере Документа (src/editor/codeTheme.ts, часть prose.ts), они читают только переменные; ни style, ни отдельного CSS — их покрывают @import "@olddevs/ui/styles.css" и строка @source из getting-started.md. Переменные объявлены с нулевой специфичностью (:where), так что потребитель перекрывает любую своим правилом. Блок кода берёт фон и основной цвет темы подсветки (#ffffff / #0d1117), а не подложку кита: цвета GitHub держат контраст 4.5:1 только на своём фоне. Одно отступление от GitHub Light — built_in и symbol темнее (#953800 вместо #e36209: у того 3.49:1 на белом). Это вторая система цвета в ките, и живёт она только в блоке кода Редактора; строчный code остаётся на токенах.

Показ. RichTextView подсветку не делает сам — иначе lowlight ехал бы в каждый показ документа. Сохранённый HTML подсвечивается на сервере хелпером highlightCodeBlocks(html) из @olddevs/ui/editor/highlight после очистки — порядок на странице RichTextView.

Картинки

Инструмент image (в full, не в basic): кнопка «Картинка» на Панели, пункт «Картинка» в Меню вставки (/img) и Markdown ![alt](адрес) при вставке. В документ картинка пишется Блоком:

<figure>
  <img src="https://cdn.example.com/a.png" alt="Озеро" data-width="50" />
  <figcaption>Подпись</figcaption>
</figure>
  • Поповер картинки — поле адреса и поле альтернативного текста. Адрес пишется как в строке браузера: cdn.example.com/a.png становится https://cdn.example.com/a.png. Принимаются только абсолютные http и https: data:, blob:, mailto:, javascript: и относительный адрес поле не примет — под ним появится ошибка. Enter вставляет картинку на место пустого абзаца под кареткой (или рядом с текущим Блоком), и каретка уходит в подпись. Escape отменяет. Внутри Dialog Escape закрывает только поповер (ADR-0041).
  • Альтернативный текст необязателен: пустой alt="" означает «декоративная картинка», скринридер её пропустит. Атрибут alt пишется всегда — без него скринридер читал бы имя файла из адреса.
  • Подпись — figcaption под картинкой, с метками и ссылками. Пустая подпись в HTML не пишется вовсе; в поле на её месте подсказка «Подпись (необязательно)». Enter на выделенной картинке ставит каретку в подпись, Enter в подписи — новый абзац под картинкой, Backspace в начале подписи выделяет картинку. Заголовок, список и блок кода из подписи не получатся: внутри figure Схема держит только подпись.
  • Ширина — пресеты 50, 75 и 100 % ширины Документа (data-width), без атрибута — свой размер картинки, но не шире Документа. Картинка стоит по центру. Мышью — ручки по бокам выделенной картинки: ширина меняется плавно, а при отпускании прилипает к ближайшему пресету (одна правка — одна отмена). С клавиатуры — кнопки-переключатели «50 %», «75 %», «100 %» в поповере выделенной картинки и на её панели; нажатый пресет повторным нажатием снимается. style у картинки не бывает.
  • Выделенная картинка (щелчок по ней или стрелки) показывает над собой свою панель: пресеты ширины, «Изменить картинку» (поповер с адресом и описанием) и «Убрать картинку». Alt+F10 на выделенной картинке ведёт в эту панель, а не в Панель форматирования; плавающая Панель над картинкой не появляется. Кнопка «Картинка» Панели в этот момент нажата (aria-pressed) и открывает поповер этой картинки, а не вставку новой.

Загрузка файлов: onUploadImage

<RichTextEditor
  value={html}
  onChange={setHtml}
  onUploadImage={async (file) => {
    const body = new FormData();
    body.set("file", file);
    const response = await fetch("/api/uploads", { method: "POST", body });
    if (!response.ok) throw new Error(String(response.status));
    return (await response.json()).url; // абсолютный https-адрес
  }}
/>
  • С загрузчиком картинку можно вставить из буфера (снимок экрана, «Копировать картинку» в браузере), перетащить файлом в поле или выбрать кнопкой «Загрузить с устройства» в поповере. Несколько файлов загружаются по одному и встают в порядке файлов.
  • Пока обещание не решено, на месте будущей картинки плейсхолдер «Загружается photo.png…» (с превью, где браузер умеет blob:), а в Документе картинки нет: ни в getHTML(), ни в onChange, ни в форме. Ход загрузки объявляет живой регион Сообщения поля (role="status", см. «Строка статуса и Сообщение поля»).
  • Успех — картинка встаёт на место плейсхолдера одной правкой: Ctrl/⌘+Z убирает именно её. Отказ (обещание отклонено или адрес вне http/https, например data: или относительный) — плейсхолдер исчезает, Документ не меняется, в истории правок ничего нет, под полем строка role="alert": «Не удалось загрузить файл photo.png. Документ не изменился.» Следующая правка её снимает. Это Сообщение поля: сообщения о файлах идут тем же каналом, что showNotice, и новое заменяет прежнее.
  • Без загрузчика файлы из буфера и перетаскивания не принимаются: под полем подсказка вставить картинку по адресу. «Копировать картинку» из браузера при этом всё равно сработает — вместе с файлом в буфере лежит HTML с адресом картинки, и она вставится по адресу, если он в политике.
  • Что не загружается. Файл не-картинка (notes.pdf) отклоняется с сообщением. Вставка из Word и со страниц, где рядом с файлом в буфере лежит HTML с текстом, идёт обычным разбором HTML: Word кладёт в буфер ещё и снимок выделения картинкой, и загружать его было бы ошибкой.
  • Загрузчик читается в момент вставки — его можно менять на лету. Отправка формы во время загрузки уйдёт без ещё не загруженной картинки.
  • Сигнала «картинка удалена из Документа» нет (ADR-0058, раздел 13): правка, отмена и вырезание со вставкой дали бы ложные срабатывания, а Документ меняют и в обход Редактора. Загруженные файлы, на которые Документ больше не ссылается, находит сервер — рецепт сравнения src.

Цвет, маркер и выравнивание

color, highlight и align входят в preset="full", но не в basic. В документ палитра пишется именами, а не значениями CSS: <span data-color="danger">, <mark data-color="danger">, <p data-align="center">. Атрибута style нет ни при загрузке, ни при вставке, ни в getHTML() — кроме одного узкого исключения, цвета автора (раздел «Свой цвет» ниже). Выравнивание из чужого style при этом не теряется: оно переводится в data-align (ниже). Конкретный цвет палитры подставляет тема (dark, light, dim), поэтому сохранённый текст читается в любой из них — и в Редакторе, и в RichTextView: классы общие (prose.ts).

Палитр две, и общих имён у них нет. Цвет текста — шесть тонов статуса кита, маркер — три предлагаемых цвета (жёлтый, зелёный, розовый) и шесть читаемых прежних. Оба пишутся в data-color, но на разных тегах: span[data-color] берёт имя из палитры текста, mark[data-color] — из палитры маркера; имя чужой палитры на теге не принимается (<span data-color="yellow"> отбрасывается вместе с обёрткой).

Цвет текста (span, пункты подменю «…» › «Цвет текста»). strong-ступень тона:

Имя data-colorПункт меню (en / ru)Цвет текста
accentAccent / Акцентныйaccent-strong
successGreen / Зелёныйsuccess-strong
warningAmber / Жёлтыйwarning-strong
dangerRed / Красныйdanger-strong
limeLime / Лаймовыйdraft-strong
mutedGray / Серыйfg-2

Маркер (mark, пункты подменю «…» › «Маркер»). Яркий, как у настоящего текстовыделителя (OLDSUI-61): три предлагаемых цвета одинаковы во всех темах (dark, light, dim) и на листе surface="paper", а текст на маркере всегда тёмный — токен --marker-text (#1a1a1a). Значения — токены --marker-<имя> компонентного слоя (theming.md), по теме их подставляет styles.css.

Палитра делится на предлагаемые имена (их даёт меню «Маркер») и читаемые (их понимают Схема, очистка и RichTextView, но в меню их нет). Читаемые — шесть прежних цветов: сохранённый Документ с <mark data-color="blue"> открывается и выгружается без потерь, RichTextView красит его прежним значением, а выбор цвета из меню заменит его на один из трёх.

Предлагаемые:

Имя data-colorПункт меню (en / ru)ТокенВсе темыТёмный текст (--marker-text)
yellowYellow / Жёлтый--marker-yellow#ffea8014.4
greenGreen / Зелёный--marker-green#aaff8014.4
pinkPink / Розовый--marker-pink#ff80bf7.5

Читаемые (значения не менялись; текст на них — основной текст темы, а не тёмный):

Имя data-colorНазвание (en / ru)Токенlightdark и dimОсновной текст, худшая из тем
blueBlue / Синий--marker-blue#d3e5ef#28456c8.93
grayGray / Серый--marker-gray#e3e2e0#5a5a5a6.33
brownBrown / Коричневый--marker-brown#eee0da#603b2c8.92
orangeOrange / Оранжевый--marker-orange#fadec9#854c1d6.32
purplePurple / Фиолетовый--marker-purple#e8deee#492f6410.28
redRed / Красный--marker-red#ffe2dd#6e36308.63

Имена yellow, green и pink те же, что были: сохранённые Документы с ними сами становятся ярче — значение принадлежит теме, а не Документу. Название читаемого цвета остаётся в имени пункта «…» › «Маркер» («Маркер: Синий»), если оно стоит на выделении.

Образец рядом с названием в меню красят тем же токеном; название остаётся главным признаком, а не окраска.

  • Контраст проверен во всех темах (npm run contrast, пары заведены из src/editor/palette.ts): цвет текста держит 4.5:1 на всех подложках приложения (худшие значения — accent и danger около 4.6); тёмный текст --marker-text на каждом из трёх предлагаемых маркеров — не менее 4.5:1 (7.5:1 на розовом, 14.4:1 на жёлтом и зелёном) в dark, light, dim и у бренда; основной текст fg-0 на шести читаемых — тоже (худший — orange, 6.32). Цветной текст на маркере ДРУГОГО цвета гейт не меряет: автор выбрал сочетание сам.

  • Маркер следует за ближайшей темой. Токены --marker-* объявлены на [data-theme] любого элемента, а не только на корне, и проза читает саму переменную (bg-(--marker-yellow)), без моста @theme и без правил под тему на потомках. Область документа во вложенной обёртке data-theme="light" внутри тёмного приложения получает светлые значения читаемых цветов; приложение вокруг неё — свои. Три предлагаемых цвета и тёмный текст от темы не зависят. Остальные токены кита (--fg-0, поверхности) объявлены на :root[data-theme] и вложенную тему не понимают — лист surface="paper" обходит это ролями --rte-* (раздел «Поверхность»).

  • Старые имена маркера переводятся. До палитры Notion маркер делил имена с цветом текста. Сохранённые тогда <mark data-color="accent"> и ещё пять читаются при загрузке (value, defaultValue) и вставке как ближайший новый цвет, и getHTML() отдаёт уже новое имя; RichTextView красит старое имя тем же цветом. Перевод идёт по оттенку подложки, а не по смыслу тона:

    Было (mark)СталоПочему
    accentblueакцент кита синий
    successgreenодноимённый цвет
    warningyellowянтарь warning-soft ближе к жёлтому, чем к оранжевому
    dangerredодноимённый цвет
    limegreenподложка лайма читается зелёной; жёлтый занят warning, и «черновик» не должен стать предупреждением
    mutedgrayодноимённый цвет

    Сохранённый документ меняет вид: оттенки теперь Notion-подобные, а не soft-ступени тонов. Сами имена в базе потребителя переписывать не обязательно — Редактор и RichTextView читают оба написания; на сервере их переводит sanitizeRichText (RichTextView).

  • accent текста — цвет бренда приложения, а не фиксированный синий: после смены акцента или бренда он меняется вместе с остальным интерфейсом. Маркер blue от акцента и бренда не зависит. Остальные имена текста от акцента не зависят.

  • Имена вне своей палитры отбрасываются вместе с обёрткой, текст остаётся: data-color="hotpink", data-color="#ff0000", имя маркера на span, регистр не прощается (Yellow), и маркер без data-color или style (голый <mark> браузер красит жёлтым и в тёмной теме светится поверх документа). Цвет из style="color: …" во вставке (Word, Google Docs) не переводится в ближайший цвет палитры, а style проходит только в строгой форме цвета автора — иначе пропадает: перевод угадывал бы за автора.

  • Код без цвета. Строчный код не совмещается с цветом и маркером: метка code исключает остальные.

  • Выравнивание — абзац и заголовки H2–H4 (в том числе абзац внутри списка или цитаты): data-align="center" или "right". «По левому краю» — умолчание, атрибут не пишется, а data-align="left" из чужого HTML читается как его отсутствие. Выравнивание из чужого HTML (value, defaultValue и вставка из Word, Excel, Google Docs) читается и в виде style="text-align: center|right" (регистр и пробелы любые) и устаревшего атрибута align="center|right" на p и h2–h4: оно становится data-align, а сами style и align в документ не попадают. left, start и justify дают «нет атрибута». Без инструмента align такое выравнивание отбрасывается вместе со style. «По ширине» (justify) в v1 нет намеренно: без переносов по слогам текст рвётся пробелами-«реками», а WCAG (1.4.8) советует его не использовать. Выравнивание физическое (лево и право), а не логическое: для документов с письмом справа налево набор придётся расширять.

  • Выключенный инструмент убирает атрибут из Схемы: data-color, style цвета автора и data-align из вставки и из value отбрасываются, текст остаётся.

  • Markdown не выражает цвет, маркер и выравнивание: getMarkdown() оставляет текст, а ==текст== разметкой маркера не становится (иначе он появлялся бы без цвета).

Свой цвет

Кроме цветов палитры автор выбирает любой другой: последний пункт обоих подменю цвета в «…» — «Свой цвет…» (editor.colorCustom) — открывает поповер кита с ColorPicker: кнопка с образцом и значением раскрывает область насыщенности, полосу тона и поле HEX; ниже — предупреждение о контрасте, «Применить» и «Отмена». Поповер встаёт у выделения, как поповер ссылки. Если на выделении уже цвет автора, он открывается на нём, а пункт подменю отмечен, и имя пункта «…» называет значение: «Цвет текста: Свой #00aa55». После «Применить», «Отмена» или Escape фокус — в Документе.

Как цвет хранится. Строго style и ничего другого, на span для текста и на mark для маркера:

<span style="color:#00aa55">текст</span> <mark style="background-color:#ffe066">маркер</mark>

Строчные буквы, ровно шесть цифр, без пробелов и ;; никаких других свойств в style и никогда вместе с data-color на одном элементе — цвет у метки один, палитры или автора, и новый выбор заменяет прежний. Это единственное исключение из правила «в документе нет style»: у цвета автора нет имени, а носитель значения в HTML — style. Исключение принято владельцем и описано в Схеме (RICH_TEXT_SCHEMA.customColor), а sanitizeRichText (RichTextView) проверяет его хуком.

Что принимается из чужого HTML (value, вставка): только это объявление, одно на элемент, на span — color, на mark — background-color; регистр букв и пробелы вокруг приводятся к записи Редактора (#00AA55 → #00aa55). Всё остальное — #abc, rgb(0, 170, 85), red, var(--x), !important, второе объявление рядом (color:#00aa55;font-size:40px из Google Docs), style на другом теге — отбрасывается вместе с обёрткой, текст остаётся. Перевод rgb() в #rrggbb технически прост, но расширил бы разбор форматами, которые должен знать и санитайзер потребителя, а угадывание за автора кит отвергает и для палитры. Цвет, скопированный из самого Редактора и вставленный обратно, проходит без потерь.

Как style пишется в DOM. Редактор ставит атрибут через setAttribute, а не через style.cssText, как делает ProseMirror для массива-описания. Разница существенная: браузер пересобирает cssText в свою запись (color: rgb(0, 170, 85);), и документ при следующей загрузке не прошёл бы строгий разбор.

Контраст — предупреждение, а не запрет. Поповер меряет цвет по ТЕКУЩЕЙ теме: фон и основной текст Редактора — --rte-background и --rte-foreground на документе. Для текста — сам цвет против фона, для маркера — тёмный текст на маркере (--marker-text), а не основной текст темы: на любом маркере, и на цвете автора тоже, текст тёмный. Порог — 4.5:1 (WCAG 1.4.3). Ниже порога в живой области поповера (role="status") появляется строка с числом («Низкий контраст: 2.1:1 с фоном редактора (нужно 4,5:1)»); цвет всё равно применяется по «Применить». Если тему прочитать не удалось (нет стилей), предупреждения нет: «не измерено» не значит «плохо».

Цвет автора не следует за темой. Палитра — токены, и один сохранённый текст читается в dark, light и dim. #ffff00 одинаков во всех темах: жёлтый текст, читаемый на тёмном фоне, на светлом сливается. Редактор предупреждает при выборе и подписывает это в поповере (editor.customColorThemeNote), но цвет не пересчитывает и не подменяет. Для документов, которые показываются в разных темах, берите палитру.

Размер. ColorPicker тянет цветовую подсистему react-aria-components — 61 кБ gzip (ADR-0038), столько же, сколько весь остальной кит, поэтому поповер грузится лениво (React.lazy) при первом «Свой цвет…». Кто его не открывает, этих килобайт не платит; в вход @olddevs/ui/editor они не входят, но сборщик потребителя вынесет их в отдельный чанк.

Таблицы

Инструмент table входит в preset="full", не в basic. Нужен пакет @tiptap/extension-table (в команде установки выше).

Одна кнопка «Таблица ▾» на Панели — и вставка, и действия, как у TinyMCE. Значок таблицы с шевроном, aria-haspopup="menu"; кнопка есть всегда, и вне таблицы пункты, которым там нечего делать, недоступны (aria-disabled), но остаются в обходе стрелками — APG советует так, чтобы стрелка не перепрыгивала пункт молча. Доступность каждого пункта — пробный прогон его команды движка, а не editor.can(): тот отвечает «да» на удаление единственной строки, которая потом молча не удалится.

ПунктВне таблицыЧто делает
Таблица ›доступенПодменю с сеткой размера (до 10×10): выбор вставляет таблицу N×M со строкой-заголовком, меню закрывается, каретка — в первой ячейке новой таблицы. Сетка открывается на последнем выбранном размере этого Редактора (сначала 3×3)
Вставить таблицу…доступенОкно (Dialog кита) с полями «Столбцы» и «Строки» — до 20 × 50, начальные значения — последний размер; Enter в любом поле вставляет, «Отмена» и Escape закрывают без таблицы. Размер из окна запоминается так же, как из сетки
Ячейка ›недоступен«Объединить ячейки» — доступно при выделении нескольких ячеек (мышью поперёк ячеек или Shift со стрелками); «Разделить ячейку» — на слитой ячейке
Строка ›недоступен«Вставить строку выше / ниже», «Удалить строку» (у таблицы из одной строки недоступно — есть «Удалить таблицу»), «Строка-заголовок» — переключатель (menuitemcheckbox): первая строка из th ↔ td
Столбец ›недоступен«Вставить столбец слева / справа», «Удалить столбец»; затем группа радио (menuitemradio) «Столбец по левому краю / по центру / по правому краю» — выравнивание столбца под кареткой. Отмечено общее выравнивание столбца; у смешанного не отмечено ничего
Сортировка ›недоступен«По возрастанию», «По убыванию» — по столбцу под кареткой (правила ниже). В таблице со слитыми ячейками оба пункта недоступны, и подпись раздела подменю объясняет почему
Удалить таблицунедоступенУбирает таблицу целиком; пункт красный (danger) и отделён разделителем

Вставка из меню и из окна встаёт новым Блоком после текущего, а в пустом абзаце — на его место; изнутри таблицы — после неё: вложенных таблиц Редактор не строит. Пункт «Таблица» Меню вставки (/table, /grid, «+») по-прежнему сразу вставляет 3×3, без выбора размера и без памяти о нём.

Клавиатура. Путь в меню — Alt+F10 (в Панель), стрелками до «Таблица ▾», Enter или ↓.

КлавишаГдеДействие
↓ / ↑меню, подменюСледующий / предыдущий пункт, недоступные — тоже
→пункт с ›Открыть подменю; фокус — на первый пункт, а у «Таблица ›» — в сетку, когда подменю встало
←подменюЗакрыть подменю, фокус — обратно на его пункт. В сетке ← двигает выбор: у сетки это её ось
← → ↑ ↓, Home EndсеткаВыбор размера, сетка растёт до 10×10 (клавиши сетки — на её странице ниже)
Enterпункт, сеткаВыполнить; действие возвращает фокус в документ, каретка остаётся в той ячейке, откуда пришли
Escapeлюбой уровеньЗакрыть только верхний уровень: сетку или подменю → меню → кнопка. В Dialog окно закрывает лишь следующий Escape (ADR-0041)

Фокус в сетку ставится, когда поповер подменю сам сообщил, что встал: место посчитано и анимация входа доиграна (рендер-пропсы placement и isEntering react-aria). Сетка, взявшая фокус сразу при монтировании, ломала меню внутри окна, а прежний таймер на 50 мс был гонкой с устройством; сигнал поповера от скорости устройства не зависит. Подменю, открытое мышью, фокус у пункта оставляет — сетку двигает наведение.

Слияние ячеек. colspan и rowspan пишутся в HTML и переживают сохранение и загрузку; таблица из Word, Excel и Google Docs вставляется со своими слияниями. Значение — целое от 2 до предела Схемы (RICH_TEXT_SCHEMA.tableSpans: 20 по горизонтали, 50 по вертикали — столько же, сколько у самой большой вставляемой таблицы); ноль, дробь, 2px, отрицательное и число сверх предела читаются как 1 (ячейка остаётся, слияние распадается): colspan="10000" из чужого HTML иначе стал бы десятью тысячами пустых ячеек. Слияние, которое вышло бы за предел, «Объединить» не делает. Строка, целиком накрытая слиянием сверху, остаётся пустой <tr></tr>.

Выравнивание столбца — data-align="center|right" на каждой ячейке столбца (th и td), теми же значениями, что выравнивание Блока: left — умолчание, атрибута нет. Оформление — общие классы prose.ts, поэтому в RichTextView столбец выровнен так же. Абзац в ячейке со своим выравниванием (инструмент align) сильнее столбца. Выравнивание чужой таблицы — style="text-align: center|right" или align="center|right" на th и td из Word, Excel, Google Docs или value — переводится в data-align ячейки (при включённом инструменте table, независимо от align); сами style и align не пишутся. Из Markdown выравнивание приходит разделителем :---:.

Сортировка переставляет строки по столбцу под кареткой:

  • строка-заголовок остаётся первой; таблица без неё сортируется целиком;
  • столбец, где все непустые ячейки — числа, сравнивается как числа: разряды пробелом (в том числе неразрывным), апострофом, запятой или точкой, дробь запятой или точкой, минус, %. Где знак один и встречается раз, решает локаль интерфейса: 1,234 у en — тысяча двести тридцать четыре, у ru — одна целая с дробью; 1.2.3 — не число;
  • столбец, где все непустые — даты ISO (2026-10-06, с временем через T или пробел) или ДД.ММ.ГГГГ, сравнивается как даты. 10/06/2026 не читается: порядок дня и месяца у локалей разный;
  • иначе — текст через Intl.Collator локали интерфейса с numeric: «ё» на своём месте, «пункт 2» раньше «пункта 10»;
  • пустые ячейки — в конце при любом направлении; порядок устойчивый (равные строки не перемешиваются); вся перестановка — одна правка, Ctrl/⌘+Z отменяет её целиком; каретка уезжает вместе со своей строкой;
  • со слитыми ячейками сортировка недоступна: строку со слиянием нельзя переставить, не разрезав его.

Прочие клавиши в документе.

  • Tab и Shift+Tab — к следующей и предыдущей ячейке по порядку чтения (содержимое ячейки выделяется). Tab в последней ячейке добавляет строку и переходит в неё, как в Word, Google Docs и Notion; это штатное поведение Tiptap, мы его оставили. Цена: Tab не выводит из таблицы, как из остального документа, — выйти можно стрелками (↓/→ из последней ячейки ставят курсор после таблицы, в том числе когда она последний Блок документа) или Alt+F10 в Панель.
  • Alt+↓ (на Mac — ⌥↓) — строка под текущей, каретка встаёт в тот же столбец новой строки; одна правка, Ctrl/⌘+Z убирает строку целиком. То же, что «Строка › Вставить строку ниже», и пункт меню показывает это сочетание. Работает только в таблице: вне неё сочетание остаётся браузеру. На macOS внутри ячейки ⌥↓ больше не переводит каретку в конец абзаца — это системное действие сочетание забирает. Для столбцов и для Alt+↑ сочетаний нет — решение владельца.
  • Выделение мышью поперёк нескольких ячеек подсвечивает их (.selectedCell, подложка выделения --selection); Delete/Backspace при выделении всех ячеек удаляет таблицу, при выделении части — очищает ячейки.

Сетка выбора размера (src/editor/TableSizeGrid.tsx, наружу не экспортируется) живёт в подменю «Таблица ›». Сетка открывается на 5×5 ячеек и растёт на столбец или строку, когда выбор доходит до её края, — до 10×10; уходит выбор назад — сетка сжимается. Верхняя строка закрашена и подчёркнута как заголовок (вставка всегда делает первую строку из th). Под сеткой — размер словами («3 столбца, 4 строки», склонение по локали). Числовых полей в меню нет — они в окне «Вставить таблицу…»: поле ввода внутри Menu react-aria закрывало бы меню, а Tab в меню не ходит. Сама сетка умеет поля (fields), это используют только её истории.

КлавишаДействие
← → ↑ ↓Сдвинуть выбор на ячейку; у края сетка растёт, у 10×10 останавливается
Home / EndПервый столбец / последний видимый столбец строки; с Ctrl — в углы сетки
EnterВставить таблицу выбранного размера
EscapeЗакрыть сетку, фокус — на пункт «Таблица ›»

ARIA сетки — шаблон grid из APG: role="grid" → row → gridcell. Фокус стоит на самой сетке, текущую ячейку называет aria-activedescendant (roving tabindex отвергнут: до ста остановок Tab вместо одной); aria-selected — на ячейках выбранного прямоугольника. Имя ячейки и живая область (role="status", вежливая) читают один текст — выбранный размер словами; наведение мыши двигает тот же выбор, что стрелки, и единственным путём не является.

Что на выходе. <table><tbody><tr><th>…</th></tr><tr><td>…</td></tr></tbody></table>, ячейка — <th><p>…</p></th>. У ячейки бывают только colspan, rowspan и data-align; ни style, ни colwidth, ни class, ни scope; thead, colgroup и caption не пишутся (строка-заголовок — обычная первая строка из th). Штатное расширение Tiptap пишет style="min-width: …" на таблице, colgroup с шириной столбцов, colwidth и style="text-align" на ячейках — кит это отключает (src/editor/tables.ts), а при загрузке и вставке такие атрибуты отбрасываются — выравнивание из них остаётся data-align.

  • Ширины столбцов не настраиваются. Ручек ресайза в v1 нет (resizable: false): ширину считает браузер по содержимому.
  • Строки с разным числом ячеек (чужой HTML с ошибкой или со слиянием сверх предела) выравниваются пустыми ячейками при старте Редактора — без onChange: это не правка автора.
  • Ячейка держит любые Блоки Схемы (абзацы, списки, цитату, блок кода), а не только абзацы: узкая ячейка вынуждала бы вставку списка в ячейку разрезать таблицу надвое.
  • Выключенный table убирает кнопку, пункт меню и расширение: таблица из вставки и из value приходит текстом ячеек, абзацами.
  • Прокрутка на узком экране. Таблица шире блока прокручивается по горизонтали внутри себя (display: block; overflow-x: auto на <table>, prose.ts), страницу не раздвигает; минимальная ширина столбца — 6 rem. Обёртки нет: RichTextView кладёт сохранённый HTML как есть, и прокрутка должна работать на голой <table>. Подробности и цена — на странице RichTextView.

Живые примеры — истории TableMenu, TableMerged, TableSortAndAlign, TableRussian, TableNarrow, TableInsertSlash и сетка отдельно — TableSizeGrid, TableSizeGridFields, TableSizeGridRussian.

Меню вставки

Список Блоков, который открывается по / в тексте и по кнопке «+» первой на Панели (инструмент insertMenu, есть в full). Пунктов два раздела:

  • «Добавить» — новый Блок после текущего: блок кода, разделитель, таблица, картинка, Раскладка (две колонки; остальные варианты — у ручки Блока), Обёртка. Картинка открывает поповер адреса и встаёт на место подготовленного пустого абзаца. Если курсор стоит в пустом абзаце, Блок встаёт на его место; иначе — новым Блоком сразу за текущим верхним Блоком, не внутри цитаты или списка; при выделенной картинке — после неё. Текст текущего Блока не трогается. Тем же путём ставит Блок «Добавить ›» ручки Блока — только всегда новым Блоком ниже, даже из пустого абзаца.
  • «Превратить в» — сменить тип текущего Блока, сохранив текст: абзац, H2–H4 (и H1 с heading1), маркированный, нумерованный и чек-лист, цитата, блок кода. Блок сначала выходит из списка или цитаты, затем становится выбранным: «Превратить пункт списка в заголовок» даёт заголовок вне списка.

В пункте — значок, подпись и подсказка: markdown-шорткат (##, -, >, ---), а где его нет — сочетание клавиш. Пункты есть только у включённых инструментов: выключенный codeBlock пропадает из обоих разделов, раздел без пунктов не показывается. Меню без пунктов, кроме абзаца, не появляется вовсе.

/ открывает меню в начале Блока или после пробела. Внутри слова (и/или), в блоке кода и в строчном коде / — обычный символ. Меню стоит у курсора; набранное после черты фильтрует пункты — по подписи на языке интерфейса, по английской подписи и по коротким именам (h2, ul, hr, todo), без учёта регистра и ё. Ничего не нашлось — строка «Ничего не найдено». Пробел заканчивает команду: меню закрывается, набранное остаётся текстом.

КлавишаСписок по / (фокус в поле)
БуквыФильтр: запрос после /
↓ / ↑Следующий / предыдущий пункт, по кругу
EnterПрименить пункт; /запрос удаляется той же правкой, отмена — одним Ctrl/⌘+Z
EscapeЗакрыть, оставив набранное; повторно на том же / меню не откроется
ПробелЗакончить команду: меню закрывается, текст остаётся

Свои Инструменты добавляют пункты в оба раздела описанием, без правки меню: они стоят в конце раздела, в порядке tools, и находятся по подписи в текущей локали, по подписи с en-US и по aliases (раздел «Свой Инструмент», «Пункты вставки и Действия над Блоком»).

«+» на Панели — обычная кнопка меню (Menu кита): стрелки, Enter, Escape и первая буква подписи; пункт применяется к Блоку под курсором или к выделению, после чего фокус возвращается в документ.

Раскладка

Инструмент layout (есть в full, в basic нет) — Блок из колонок. Вставляется пунктом «Раскладка» Меню вставки (/layout или «+»): две равные колонки, каретка в первой. Шесть вариантов — Одна колонка, Две колонки, Три колонки, Четыре колонки, 60/40, 40/60 — выбираются в меню ручки Блока: «Добавить › Раскладка ›» вставляет Раскладку под Блоком (из колонки — под всей Раскладкой), а вариант переключают «Раскладка ›» у ручки Блока в колонке и «Формат ›» у ручки Раскладки, выделенной целиком (Backspace на Блоке под ней); у обоих отмечен текущий вариант. Своей кнопки на Панели у Раскладки нет.

  • У Блоков колонки — своя ручка, у левого края колонки на уровне первой строки Блока (у правой колонки — в промежутке между колонками). Колонка для них — то же, что Документ: «Добавить ›» ставит новый Блок ниже в этой же колонке, «Вверх» и «Вниз» не выходят из неё, «Удалить» единственного Блока оставляет пустую колонку, «Формат ›» меняет тип самого Блока. Вариант Раскладки — отдельным пунктом «Раскладка ›»: каретка почти всегда в колонке, и ручка Раскладки целиком видна редко.

  • Колонка — отдельная область. В ней любые Блоки, кроме другой Раскладки: вложенности нет на уровне Схемы, поэтому её не получить ни вставкой, ни из value. Раскладка — только Блок верхнего уровня: пункт «Раскладка» из колонки, списка или цитаты ставит новую Раскладку под текущей, а Раскладка из буфера, вставленная в колонку, список, цитату или ячейку, разворачивается в свои Блоки. Другие пункты «Добавить» из колонки встают под текущим Блоком в этой же колонке; перестановка (Alt+Shift+↑/↓) и дублирование Блока тоже не выходят из колонки.

  • В Редакторе колонки обведены серым пунктиром, в RichTextView рамок нет — граница нужна автору, а не читателю. Ширины — пресеты варианта, перетаскиванием не меняются.

  • Смена варианта не теряет текст. Если колонок становится меньше, Блоки лишних колонок переезжают в конец последней оставшейся (пустые лишние колонки просто уходят), если больше — добавляются пустые. Вставка и смена варианта — одна правка, отмена — одним Ctrl/⌘+Z. «Одна колонка» — Раскладка из одной колонки; снять Раскладку целиком — «Удалить» у её ручки или Backspace на Блоке под ней (первое нажатие выделяет Раскладку, второе удаляет).

  • Узкий экран. Колонки стоят рядом, только когда сам Документ достаточно широк: две — от 30 rem (480 px), три — от 40 rem (640 px), четыре — от 48 rem (768 px); уже — столбцом, в порядке чтения. Порог меряет ширину Документа контейнерным запросом, а не ширину окна, поэтому в узком Drawer на широком мониторе колонки тоже встают столбцом. Корень Документа с Раскладкой становится контейнером (container-type: inline-size) — в сжимающем контексте (элемент flex-ряда без ширины) дайте полю или RichTextView ширину.

Клавиша в колонкеЧто делает
Tab / Shift+TabВ начало следующей / предыдущей колонки. В списке, таблице и блоке кода Tab остаётся их; из крайней колонки уходит браузеру — фокус покидает Редактор
→ / ← у края текстаВ начало соседней колонки справа / в конец соседней слева; из крайней — из Раскладки
↓ / ↑ у нижней / верхней строкиИз Раскладки — к Блоку под ней / над ней; у края Документа — курсор-щель
Backspace в начале колонкиКолонки не сливаются и текст из Раскладки не выходит; пустой заголовок, как везде, становится текстом

Разметка — div с data-block, ширины — вариантом в data-layout, без style (доли колонок в процентах через дефис):

<div data-block="layout" data-layout="60-40">
  <div data-block="column"><p>Широкая колонка</p></div>
  <div data-block="column"><p>Узкая колонка</p></div>
</div>

Значения data-layout — 100, 50-50, 33-33-33, 25-25-25-25, 60-40, 40-60 (RICH_TEXT_SCHEMA.layouts). HTML чужого редактора с колонками div[data-block="column"] и ширинами в style="flex-basis: 40%" читается как Раскладка ближайшего варианта, style отбрасывается. Если число колонок разошлось с вариантом, Редактор приводит вариант к числу колонок. Без инструмента layout Раскладка из value и вставки разворачивается в Блоки подряд.

Markdown колонок не знает: getMarkdown() выгружает Блоки колонок подряд, слева направо, а setMarkdown() Раскладку не создаёт, даже из сырого HTML. Строки — editor.layout и editor.layoutVariant в каталоге, английском и русском; значок пункта — ColumnLayoutIcon. Решение — ADR-0056.

Обёртка

Инструмент wrapper (есть в full, в basic нет) — Блок, собирающий группу Блоков, как Wrapper в Redactor. Без тона своего вида у Обёртки нет: она держит Блоки вместе — их можно двигать, дублировать и удалять одним Блоком. С тоном она становится заметкой или предупреждением, как callout GitHub и Notion (подраздел «Тон» ниже). Вставляется пунктом «Обёртка» Меню вставки (/wrapper или «+») и «Добавить › Обёртка» у ручки Блока: пустая Обёртка с одним абзацем встаёт под текущим Блоком, каретка — в абзац внутри. Своей кнопки на Панели у Обёртки нет.

  • Внутри — любые Блоки: абзац, заголовок, списки, цитата, блок кода, таблица, картинка, разделитель. Кроме другой Обёртки и Раскладки: вложенности нет на уровне Схемы, поэтому её не получить ни вставкой, ни из value. Обёртка, вставленная в Обёртку, список, цитату или ячейку, разворачивается в свои Блоки, а Раскладка внутри Обёртки — в Блоки колонок.
  • Место. Обёртка стоит в Документе и в колонке Раскладки. Из колонки пункт «Обёртка» ставит её под текущим Блоком в той же колонке; из Обёртки, списка, цитаты или ячейки — под их Блоком. Вставка — одна правка, отмена — одним Ctrl/⌘+Z.
  • У Блоков Обёртки — своя ручка у левого края Обёртки. Обёртка для них — то же, что Документ: «Добавить ›» ставит новый Блок ниже в этой же Обёртке, «Вверх», «Вниз» и Alt+Shift+↑/↓ не выходят из неё, «Удалить» единственного Блока оставляет пустой абзац.
  • Ручка самой Обёртки — когда она выделена целиком (Backspace на Блоке под ней): «Вверх», «Вниз», «Дублировать» и «Удалить» берут её вместе с содержимым.
  • «Снять обёртку» — Действие над Блоком Инструмента wrapper: пункт меню ручки у самой Обёртки и у Блока прямо в ней, в секции действий Инструментов перед «Удалить». Блоки встают на место Обёртки, каретка остаётся в своём тексте. Одна правка, одна отмена.
  • В Редакторе Обёртка без тона обведена серым пунктиром с внутренним отступом — тем же видом, что колонки Раскладки; в RichTextView рамки нет, а отступы между Блоками — как в Документе: граница нужна автору, а не читателю. Обёртку с тоном видно и читателю — тем же видом, что в Редакторе.
Клавиша в ОбёрткеЧто делает
Enter в пустом последнем абзацеЕсли в Обёртке есть другие Блоки — абзац переезжает под Обёртку, каретка с ним: выход без мыши. Единственный абзац остаётся внутри
Backspace в начале / Delete в концеТекст не выходит из Обёртки и не сливается с Блоком снаружи; пустой заголовок, как везде, становится текстом
Backspace в единственном пустом абзацеСнимает пустую Обёртку целиком: на её месте пустой абзац, каретка в нём (в колонке — в той же колонке). Одна правка, одна отмена. Delete Обёртку не снимает
↑ / ↓ у края ОбёрткиК Блоку над ней / под ней; у края Документа — курсор-щель, чтобы поставить абзац до или после Обёртки
Tab / Shift+TabНе перехватываются: как в Документе (в колонке — переход по колонкам). Ловушки клавиатуры нет

Разметка — div с data-block="wrapper" и Блоками внутри; единственный свой атрибут — тон data-tone, без тона его нет:

<div data-block="wrapper">
  <h3>Перед началом</h3>
  <p>Проверьте доступ к репозиторию.</p>
</div>

Это разметка Redactor: его HTML с Обёрткой читается при вставке и в value как есть, а class, style и прочие атрибуты на ней отбрасываются. Без инструмента wrapper Обёртка из value и вставки разворачивается в Блоки подряд, текст не теряется — тон уходит вместе с ней.

Тон

Тон (OLDSUI-78) делает Обёртку заметкой: мягкая подложка тона и полоса 3 px слева сильной ступенью тона, текст — обычный текст Документа, внутренний отступ — как у Alert (12 px по бокам, 10 px сверху и снизу). Вид один в Редакторе и в RichTextView: пунктира у Обёртки с тоном нет и в Редакторе — границу видно по подложке и полосе, а второй рамки, которой нет у читателя, автор не видит. Без тона — прежний вид: пунктир только в Редакторе.

Выбирается в подменю «Тон ›» меню ручки — у Блока прямо в Обёртке и у самой Обёртки, выделенной целиком; «Снять обёртку» стоит ниже, в секции действий перед «Удалить». Пункты-радио (menuitemradio): «Без тона» и пять тонов словами — по смыслу заметки, как метки GitHub alerts; рядом с именем — маленький образец подложки и полосы. Текущий отмечен, имя пункта несёт его: «Тон: Заметка» / «Tone: Note», без тона — «Тон: Без тона». Смена тона — одна правка, одна отмена; каретка остаётся на месте, фокус — в Документе. Выбор текущего тона правки не делает.

data-toneПункт меню (ru / en)GitHub alertПодложка (тема)Полоса (тема)Лист paper: подложка / полоса
accentЗаметка / Note> [!NOTE]--accent-soft--accent-strong--accent-soft / --accent ¹
successСовет / Tip> [!TIP]--success-soft--success-strong--od-green-2-a12 / --od-green-1
neutralВажно / Important> [!IMPORTANT]--bg-3--fg-3--od-cloud-11 / --od-cloud-4
warningВнимание / Warning> [!WARNING]--warning-soft--warning-strong--od-amber-2-a14 / --od-amber-1
dangerОсторожно / Caution> [!CAUTION]--danger-soft--danger-strong--od-red-3-a12 / --od-red-1

¹ В светлом приложении полоса «Заметки» на листе — светлый accent-strong, как ссылка (раздел «Поверхность»).

  • Имена — словарь Tone кита, а не слова меню: в базу уходит data-tone="warning", подпись меню и язык интерфейса его не меняют. Цвет подставляет тема, как у data-color: сохранённый Документ читается в dark, light, dim, с любым акцентом и брендом. Значения — роли --rte-tone-<тон>-background и --rte-tone-<тон>-border (src/editor/tokens.ts): у темы они ведут в статусные токены кита, у листа surface="paper" — в примитивы светлой темы. Свой цвет тона (style) Схема не знает.

  • «Важно» — нейтральный тон, и у него два отступления от TONE_TOKENS.neutral. Подложка — bg-3, а не bg-2: на bg-2 стоит само поле Редактора, и «Важно» в нём осталось бы без подложки; bg-3 — подложка приложения, под которую откалиброваны текстовые токены, и по весу она ближе к мягким подложкам остальных тонов в тёмных темах, чем bg-4. В светлой теме эта подложка почти белая — тон там держит полоса. Полоса — fg-3, а не декоративная line: она несёт смысл и держит 3:1.

  • Контраст (npm run contrast, пары тонов Обёртки): текст Документа на каждой подложке — не меньше 4.5:1 (худший — warning, 7.15 в тёмной теме на surface-elevated; на листе — 13.3), полоса — не меньше 3:1 и к подложке приложения вокруг Обёртки (худшая — 4.51 у «Заметки» на листе), и к своей заливке (худшая — 3.68, «Заметка» на листе; в теме приложения — 4.60). Во всех темах, акцентах, бренде и на листе.

  • Вторичный текст в Обёртке с тоном берёт более контрастные роли. Ступени fg-1–fg-3 и accent-strong откалиброваны под подложки приложения, а на цветных подложках тонов цитата падала до 4.23:1, ссылка — до 3.97, подпись и серый текст — до 3.59. Поэтому внутри Обёртки с тоном ссылка, цитата (и подпись языка в шапке блока кода), подпись картинки, выполненный пункт чек-листа, серый цвет текста (data-color="muted") и подсказка пустой подписи читают роли --rte-tone-link, -secondary, -muted, -muted-foreground:

    ЧтоРоль снаружиВ теме приложенияНа листе paper
    ссылка--rte-link--fg-0--od-cloud-1
    цитата, шапка блока кода--rte-secondary--fg-0--od-cloud-2
    подпись картинки, выполненный пункт, серый цвет текста--rte-muted, -text-muted--fg-0--od-cloud-3
    подсказка пустой подписи--rte-muted-foreground--fg-0--od-cloud-3

    В теме приложения на всех пяти подложках 4.5:1 держит только fg-0 (fg-1 даёт 4.23 на «Заметке»), поэтому внутри тона вторичный текст — цвета основного: цитату по-прежнему отличает полоса, выполненный пункт — зачёркивание, подпись — кегль, ссылку — подчёркивание (оно у ссылки Документа всегда). На листе cloud-2 и cloud-3 проходят и на подложках тона и остаются; поднимаются ссылка (акцент давал 3.68) и подсказка. Худшая пара — 7.15:1 в теме (warning, тёмная тема), 5.30:1 на листе (подпись и подсказка на «Заметке»). Снаружи Обёртки и в Обёртке без тона роли прежние. Цвет текста автора — палитра и свой цвет — на подложке тона гейт не меряет: это выбор автора.

  • Неизвестный тон отбрасывается — и при разборе (value, вставка, HTML-режим), и в sanitizeRichText: data-tone="purple", "Accent" и data-tone на любом другом теге снимаются, Обёртка остаётся без тона. Обёртка в Обёртке, списке, цитате или ячейке снимается вместе с тоном. RichTextView с неочищенным чужим тоном рисует Обёртку без вида.

  • Тон — оформление, а не смысл. Роли (note, alert) и aria-* в сохранённом HTML нет: скринридер читает Блоки Обёртки, как Блоки вокруг. Поэтому смысл заметки пишется словом в её тексте — «Внимание: …», «Совет: …»: цвет подложки не должен быть единственным носителем смысла (WCAG 1.4.1).

  • HTML-режим показывает data-tone на Обёртке как есть; применяется он через Схему, как value.

Markdown — GitHub alerts. getMarkdown() выгружает Обёртку с тоном цитатой с меткой первой строкой, дальше её Блоки внутри цитаты:

> [!WARNING]
>
> ### Перед обновлением
>
> Сделайте резервную копию базы.

setMarkdown() и вставка Markdown читают такую цитату обратно в Обёртку с тоном: метка — одна на первой строке, регистр любой ([!note]). Обычная цитата, метка с текстом на той же строке (> [!NOTE] текст), чужая метка ([!INFO]) и alert внутри списка или цитаты остаются цитатой — GitHub их alert'ом тоже не показывает. Вставка alert'а туда, где Обёртки быть не может (в Обёртку, список, цитату, ячейку), и setMarkdown() без инструмента wrapper дают Блоки подряд, без метки — как Обёртка из HTML в том же месте. Обёртка без тона в Markdown по-прежнему — Блоки подряд, а сырой <div data-block="wrapper"> внутри Markdown Обёртку не создаёт.

Строки — editor.wrapper («Обёртка»), editor.unwrap («Снять обёртку»), editor.wrapperTone («Тон: {current}»), editor.wrapperToneNone («Без тона») и editor.wrapperToneName (имена пяти тонов) в каталоге, английском и русском; значки — WrapperIcon, UnwrapIcon и PaletteIcon у «Тон ›». Решение — ADR-0057.

Эмодзи

Инструмент emoji (есть в full, в basic нет) — кнопка «Эмодзи» на Панели сразу за «Таблицей ▾», перед отменой и повтором. Она открывает поповер кита (Popover) с сеткой курированного набора: «Избранное» (20) и шесть категорий по десять — «Смайлы», «Жесты», «Животные», «Еда», «Занятия», «Путешествия». Сетка — одна, категории идут подряд с подписями; полного набора Unicode, поиска, «недавних», оттенков кожи и :шорткодов: нет и не будет — это быстрый путь к привычным символам, а не палитра ОС.

Эмодзи — обычный текст. Своего Блока и Метки у него нет, как у clearFormatting: Схема, санитайзер и Markdown не меняются, а вставленный символ попадает в выходной HTML, в RichTextView и в getMarkdown как любой другой текст. Выключить кнопку — убрать emoji из tools; вместе с ней уходит и вся сетка.

  • Вставка — щелчок по ячейке или Enter (пробел) в сетке. При пустом выделении эмодзи встаёт в позицию каретки, при непустом — в конец выделения, не стирая его. Каретка встаёт сразу за эмодзи, поповер закрывается, фокус возвращается в Документ. Вставка — одна правка на отмену.
  • maxLength. Вставка идёт обычной правкой, поэтому предел её останавливает: достигнут — эмодзи не вставляется; осталось меньше, чем занимает символ (большинство эмодзи — два кодовых блока), — тоже не вставляется, половинки не бывает.
  • Нет куда вставлять (выделена картинка или ячейки таблицы) — кнопка aria-disabled, поповер не открывается.
  • Состав набора — данные в одном серверно-безопасном модуле без React и DOM: категории по порядку, у каждого эмодзи символ и ключ имени. Один эмодзи вправе стоять в двух категориях («Избранное» повторяет часть остальных). Библиотеки эмодзи нет.

Клавиатура. Фокус при открытии — на сетке, как у сетки размера таблицы; текущую ячейку называет aria-activedescendant.

КлавишаДействие
← / →Предыдущий / следующий эмодзи, через границу категории
↑ / ↓На строку выше / ниже (в строке десять эмодзи)
Home / EndВ начало / в конец строки; с Ctrl — первый / последний
Enter, пробелВставить текущий эмодзи
EscapeЗакрыть поповер и вернуть фокус в Документ

Внутри Dialog и Drawer выбор мышью и клавишами работает, а Escape закрывает только поповер: слои Radix — один стек (ADR-0041). Строки — editor.emoji (имя кнопки), editor.emojiGrid (имя сетки), editor.emojiCategory (подписи категорий) и editor.emojiName (имя каждого эмодзи) — в каталоге, английском и русском.

HTML-режим

Инструмент source (есть в full, в basic нет) — кнопка-переключатель «HTML» на фиксированной Панели, в группе «Блок целиком» сразу за «+», как у Redactor. Она заменяет Документ моноширинным полем с HTML текущего Документа; повторное нажатие возвращает визуальный режим. Сочетания клавиш у режима нет, справочник сочетаний в режиме доступен.

<RichTextEditor preset="full" value={html} onChange={setHtml} /> // кнопка «HTML» уже на Панели
<RichTextEditor tools={["bold", "italic", "link", "history", "source"]} /> // или явно в tools

Что видно. HTML отформатирован: Блок верхнего уровня — на своей строке, дети контейнеров Блоков (пункты списков, строки и ячейки таблицы, колонки Раскладки, части figure, пункт чек-листа) — с отступом в два пробела, содержимое Блока — на строке его тега (пробелы текста не меняются), содержимое pre — как есть. Форматирование без потерь: неизменённый текст применяется в тот же Документ. Пустой Документ открывается пустым полем, а не <p></p>. Подсветки синтаксиса нет.

Вид. Поле всегда тёмное, цветом кита, а не палитрой подсветки Блока кода: в dark и dim — самая глубокая подложка темы с её текстом и рамкой, в light и на листе paper — те же значения тёмной темы (переменные --rte-source-background, --rte-source-foreground, --rte-source-border, их можно перекрыть). Скругление — как у кода, шрифт — моноширинный 13 px, как у CodeView; отступы — как у области Документа. Высота при входе — не меньше высоты Документа, дальше поле растёт по содержимому, растягивается по вертикали рукой. С maxHeight или resizable поле живёт в той же области прокрутки, что и Документ (раздел «Высота поля»).

Применение — при выходе и сбросе. Текст поля проходит через Схему тем же разбором, что входящий value (всё, чего в Схеме нет, отбрасывается — script, обработчики on*, произвольные style и class, javascript:; разметка выключенных инструментов снимается, текст остаётся), и заменяет Документ одной правкой — одна отмена Ctrl/⌘+Z в Документе возвращает его к состоянию до применения. Применяют:

  • выход из режима кнопкой «HTML»;
  • уход фокуса из поля (blur) — текст поля при этом не переформатируется: автор продолжает со своим текстом, свежее форматирование видно при следующем входе;
  • отправка формы (submit, в фазе захвата — раньше onSubmit потребителя) и сборка new FormData(form);
  • ref.getHTML() и ref.getMarkdown();
  • переход в readOnly и пропажа кнопки (смена toolbar) — режим закрывается.
  • размонтирование Редактора — например, Escape в поле HTML закрыл Dialog вокруг него: blur до поля при этом не доходит, а правка не теряется.

Если текст не менялся с входа или прошлого применения, либо разбор дал тот же Документ (поменялись только пробелы между Блоками), — ни правки, ни шага истории, ни onChange: «открыл — посмотрел — закрыл» безопасно. Иначе onChange получает новый HTML сразу, без changeDelay, как на blur; набор в поле HTML onChange не зовёт. Скрытое поле name обновляется вместе с применением. Проверка required в режиме следует тексту поля на каждом его изменении, а не применённому Документу: браузер проверяет форму до события submit, поэтому form.requestSubmit() с фокусом в поле HTML отправляет набранное в пустой Документ и блокирует очищенное поле. Текст разбирается той же Схемой, что и при применении, и пустой он ровно тогда, когда пустым был бы Документ, — <p></p> или одни отброшенные Схемой теги блокируют отправку, <hr> — нет. Без required текст на наборе не разбирается.

Внешние замены в режиме. Новый value (не равный последнему отданному), setMarkdown() и clear() заменяют Документ, а текст поля перезаписывается форматированием нового Документа: владелец значения главнее несохранённой правки. Свой же отданный value, вернувшийся пропом, поле не сбивает.

  • maxLength применение не режет — как setMarkdown: счётчик показывает превышение. В режиме счётчик отражает последнее применённое состояние.
  • disabled и <fieldset disabled> выключают поле HTML и кнопку; режим остаётся открытым.
  • Что выключено в режиме. Остальные кнопки и меню Панели — aria-disabled (остаются в обходе стрелками, но не срабатывают); ручки Блока, меню выделения и Меню вставки по / нет. Движок остаётся смонтированным и скрытым: история правок жива.
  • Где кнопки нет. У плавающей Панели и меню выделения (toolbar="floating", "bubble") — они живут над выделением в Документе, а в режиме Документа нет; при toolbar="none" и в readOnly нет Панели. На сенсорном экране Панель всегда фиксированная — значит, режим там есть.
  • SSR. Режим — внутреннее состояние, не проп; он всегда стартует визуальным, на сервере рисуется то же, что без него.

RichTextView, Схема, санитайзер sanitizeRichText и белый список режим не меняют: он не расширяет Схему, а лишь даёт её вход. Строки — editor.source (имя кнопки, «HTML») и editor.sourceField (имя поля, «HTML-код документа» / «HTML source») в каталоге, английском и русском; значок — SourceIcon.

Строка статуса и Сообщение поля

Под полем внутри рамки два независимых места. Строка статуса — то, что экран кладёт сам: «Сохранено», число слов, соавтор. Сообщение поля — то, что сообщают кит и Инструменты: результат загрузки, отказ вставки. Первое — React-узел пропа status, второе — данные и команда движка.

Строка статуса: status

<RichTextEditor
  value={html}
  onChange={setHtml}
  maxLength={2000}
  status={
    <span className="flex gap-3">
      <span role="status">{saved ? "Сохранено" : ""}</span>
      <span>{countWords(html)} слов</span>
    </span>
  }
/>
  • Строка стоит внутри рамки поля, в строке счётчика maxLength: своё содержимое у начала, счётчик кита в конце. Без maxLength или пока счётчика нет (он появляется у 80 % предела) строка тянется одним своим содержимым. Пустой status (null, undefined, false, "") строки не рисует.
  • Контейнер без role и aria-live: кит строку не озвучивает. Число слов меняется на каждую правку, и зачитывать его вслух значило бы говорить поверх набора. Нужно, чтобы «Сохранено» прозвучало, — оберните именно его в role="status" (пример выше; с автосохранением по onBlur — рецепт на странице API расширения). Не оборачивайте в role="status" всю строку.
  • Видна и при readOnly, и при disabled: она часть рамки поля, а не правки. При disabled рамка приглушена целиком, как у прочих полей, вместе со строкой.
  • Счётчик сохраняет свой id и остаётся в aria-describedby поля, пока виден; содержимое экрана в описание поля не входит.
  • Данные берите из своего состояния: число слов — из value/onChange, «Сохранено» — из ответа сервера. У Инструментов к Строке статуса доступа нет: у неё нет Инструмента-владельца.
  • Цвет и размер по умолчанию — вторая ступень текста, 11 px, цифры моноширинные. Свой цвет задайте у своего узла: на подложке поля третья ступень 4.5:1 не держит.
  • В RichTextView строки статуса нет: он показывает очищенный HTML без движка.

Сообщение поля: showNotice

Одно место под полем и одно последнее сообщение: новое заменяет прежнее, showNotice(null) снимает. Сообщение — данные, а не React-узел: тон и готовый текст.

type FieldNotice = { tone: "status" | "error"; text: string };

// Экран — через `ref.editor`:
ref.current?.editor?.commands.showNotice({ tone: "error", text: "Не удалось вставить карточку." });
ref.current?.editor?.commands.showNotice(null);
  • status невидим и только озвучивается: текст идёт в живой регион role="status" вне Документа. Ход загрузки глазу показывает плейсхолдер в Документе, а живые регионы внутри contenteditable читаются не везде. Остаётся, пока его не заменят или не снимут; правка автора его не трогает.
  • error видна строкой role="alert" под полем, над Строкой статуса, до следующей правки автора, нового сообщения или showNotice(null). Правка — любое изменение Документа; замена value снаружи, clear() и setMarkdown() правкой не считаются. Ошибку нужно показывать там, где Документ при этом не изменился: без строки это выглядело бы как «ничего не случилось».
  • Своих длительностей, кнопок закрытия и спиннеров у сообщения нет: оно уходит само только с правкой, остальное — командой.
  • Документ команда не меняет: ни транзакции, ни шага истории, ни onChange. editor.can().showNotice(…) сообщения не показывает.
  • Сообщение без текста (text: "") — то же, что null. Текст кит не переводит: он показывается как есть, без разметки. Сообщения самого кита (файлы) идут через каталог editor.*, строки своих Инструментов — из их каталога.
  • Кто зовёт. Экран — ref.current.editor.commands.showNotice(…). Свой Инструмент — из Части для движка: this.editor.commands.showNotice(…) в расширении Tiptap. Команда есть у каждого Редактора независимо от tools, в том числе без image. Тип FieldNotice экспортируется из @olddevs/ui/editor; он же даёт editor.commands.showNotice типы.
  • Сообщения о картинках идут этим же каналом: «Загружается photo.png…» и «photo.png загружен» — status, отказ загрузки, «не картинка» и «файлы не принимаются» — error. Свой error и сообщение о файле делят одно место: приходит новое — прежнее уходит.

Высота поля: maxHeight и resizable

По умолчанию Редактор растёт вместе с Документом, и длинный текст раздвигает страницу. maxHeight ограничивает область Документа: до предела поле растёт как обычно, дальше Документ прокручивается внутри рамки. resizable добавляет у нижнего края области ручку высоты. Решение — ADR-0059.

<RichTextEditor aria-label="Описание" maxHeight={320} value={html} onChange={setHtml} />
<RichTextEditor aria-label="Заметка" maxHeight="24rem" resizable defaultValue={draft} />
  • Единицы. Число — пиксели, строка — любая CSS-длина ("24rem", "60vh"). Строк, как maxRows у TextControl, нет: у Блоков разная высота.
  • Что прокручивается. Только область Документа: Документ, его Скелет, поле HTML-режима и ручка Блока. Фиксированная Панель над ней и Строка статуса с Сообщением поля под ней остаются на месте, рамка не уезжает. Полоса прокрутки стоит у рамки.
  • resizable с maxHeight — maxHeight становится стартовой высотой, а не пределом: поле тянется и выше, и ниже. Две системы, которые одновременно распоряжаются высотой, дают поле, прыгающее на каждой правке, — тот же довод, что у autosize в TextControl. Без maxHeight старт — высота Документа. Ниже 128 px (несколько строк) область не сжимается.
  • Ручка — своя, а не уголок браузера. Нативный resize: vertical браузер рисует своим уголком, который не берёт тему: в тёмной это белый квадрат на рамке. Ручка кита — полоса у нижнего края области с хватом по центру, в словаре полосы SplitPanel: хват --line, при наведении --fg-2, при тяге и фокусе — акцент. Видимая полоса 10 px, зона попадания 26 px (WCAG 2.5.8). С клавиатуры — ползунок «Изменить высоту поля» (editor.resizeField, значение — editor.fieldHeight): стрелки вверх и вниз по 16 px, с Shift — по пикселю; тяга мышью переводит фокус на него, чтобы дотянуть стрелками. Двойной щелчок по полосе возвращает стартовую высоту. Высота, натянутая рукой, живёт в поле до размонтирования: сохранить её между визитами — дело экрана.
  • Ручка Блока и плашки едут с текстом. Ручка «⋮» лежит внутри области и прокручивается вместе с Блоком, за краем области она срезается и на Панель не заходит. Плавающая Панель, меню выделения и панель картинки слушают прокрутку области, а не окна; у края области плашка переворачивается под выделение. Карточка у текста и Меню вставки по «/» следуют за диапазоном и кареткой так же: их якорь привязан к Документу (contextElement).
  • Без пропов ничего не меняется: область — простой блок без стилей, поле растёт с Документом, Панель липнет к окну, как раньше.

Поверхность: surface

surface="theme" (по умолчанию) — Документ в теме приложения, как в Notion, Confluence и Slack. surface="paper" — светлый лист в любой теме (dark, dim, бренд), как «Сменить режим» в Word: текст, ссылки, цвет текста и маркер, таблицы, подписи картинок, чек-лист, блок кода (GitHub Light) и цвет автора выглядят как в светлой теме.

<RichTextEditor surface="paper" aria-label="Текст письма" value={html} onChange={setHtml} />

Когда брать лист. Документ, который уйдёт из приложения светлым: экран подготовки к печати или PDF, редактор письма, шаблон документа. Автор видит текст таким, каким его прочтут, а цвет автора не теряет контраст при смене темы. Для заметок, комментариев и описаний задач лист не нужен: документ живёт в приложении и следует его теме.

Что остаётся в теме приложения. Лист — только редактируемая область. Панель (фиксированная и плавающая), меню вставки, поповеры ссылки, картинки и цвета, меню таблицы, окна, подписи и счётчик под полем, рамка поля и кольцо фокуса вокруг него — в теме приложения: это интерфейс, а не документ. Край листа — рамка --line и тень --shadow-1 темы приложения, поэтому лист отделён и на тёмной подложке, и на светлой.

Как устроено. На листе стоит вложенный data-theme="light" — по нему маркеры (--marker-*) и тема подсветки берут светлые значения. Токены кита (--fg-0, поверхности, --accent-strong) объявлены только на :root[data-theme] и вложенную тему не понимают, поэтому Документ читает не их, а роли --rte-* (src/editor/tokens.ts): у темы они ведут в токены кита, у листа — в те же примитивы --od-*, что назначает светлая тема. Совпадение со светлой темой сверяет тест разбором styles.css.

  • Акцент на листе — заливка темы приложения (--accent), а не светлый accent-strong: у бренда и фиолетового акцента светлая ступень существует только в светлом приложении. Заливка рассчитана под белый текст поверх и поэтому как текст на белом листе держит 4.5:1 у любого бренда. В светлом приложении ссылка, цвет accent и фокус на листе — ровно светлый accent-strong.
  • Фокус внутри листа (кнопка языка блока кода, флажки чек-листа) — контур того же акцента: светлый accent-strong тёмной темы на белом не держит 3:1.
  • Шапка блока кода на листе — светлая: фон и текст — тема подсветки по вложенному data-theme, рамка и черта — роли листа. Кнопка языка, подпись языка и «Копировать» красятся ролью --rte-secondary, наведение — --rte-control-hover, а не токенами ghost-кнопки: те остались бы тёмной темой, и светлый значок лёг бы на белую шапку.
  • Тон Обёртки на листе — свои роли --rte-tone-* со значениями светлой темы (раздел «Обёртка», «Тон»): статусные *-soft и *-strong темы приложения на белом листе были бы тёмными.
  • Контраст меряет npm run contrast: каждая пара листа — против самого листа, в каждой теме × акценте × бренде.
  • RichTextView с тем же surface показывает документ тем же листом.

Хэндл и своя панель экрана

Экран управляет полем через ref (RichTextEditorHandle) и рисует свою панель на хуке useRichTextEditorState. Оба — из @olddevs/ui/editor:

import { RichTextEditor, useRichTextEditorState } from "@olddevs/ui/editor";
import type { RichTextEditorHandle } from "@olddevs/ui/editor";

Методы Хэндла

МетодЧто делает
insertHTML(html): booleanВставляет HTML у каретки на месте выделения. Блок вставляется тем же HTML-ом.
insertText(text): booleanВставляет текст буквально, без разбора HTML; перенос строки — мягкий перенос (в блоке кода — символ переноса).
getText()Текст Документа без разметки; Блоки разделены пустой строкой, атом — его Текстом атома.
getSelectedText()Выделенный текст; для каретки — "". Атом входит в него только целиком.
isEmpty(): booleanПустота по правилу кита — то же, что решает required и что пишется в форму "": любой атом (hr, картинка, свой), мягкий перенос и таблица, даже с пустыми ячейками, — содержимое; Блок без текста (абзац, заголовок, список, цитата) — нет.
focus(position?)"end" (по умолчанию — как всегда), "start" или "restore": вернуть фокус туда, где было выделение, — после своего диалога или меню.
blur()Снять фокус с Документа.
undo(), redo()Отменить и повторить правку; работают при включённом Инструменте history.
canUndo(), canRedo()Доступность undo и redo: false без history.
getHTML(), getMarkdown(), setMarkdown(md), clear()Как прежде, см. «Public API» и «Markdown».
editorСырой Editor Tiptap для команд Меток, Блоков и своих Инструментов; его API следует мажору Tiptap, а не контракту кита.

Вставка. insertHTML и insertText возвращают true, если Документ изменился, и false, если нет: движка ещё нет, поле только для чтения или выключено, вставка вышла бы за maxLength, либо Схема отбросила всё вставленное. HTML проходит через Схему, как входящий value: Блок выключенного Инструмента отбрасывается (текст внутри него остаётся обычным абзацем), и если не осталось ничего — вставка не выполняется. Вставка за пределом maxLength не выполняется целиком и возвращает false: шаблон, обрезанный посередине, хуже отказа; что сказать человеку, решает экран. Каретка встаёт после вставленного, фокус метод сам не берёт — если вставка идёт из кнопки или диалога, начните с focus("restore"). У поля, которое ни разу не фокусировали, каретка в начале Документа.

Правка автора и Замена снаружи. Вставка, undo и redo — правка автора: отдельный шаг истории (отмена убирает вставку одну, а не вместе с набором перед ней), onChange приходит после changeDelay, предел maxLength действует. clear(), setMarkdown() и новый value — замена снаружи: onChange приходит сразу, предел её не режет. В readOnly и у выключенного поля вставка возвращает false, а undo и redo ничего не делают.

До Готовности методы тихие — на сервере, до монтажа и пока работают загрузчики Инструментов (раздел «Свой Инструмент»). Движок создаётся в браузере после первого рендера и загрузчиков, поэтому вызов в этот момент не бросает и не копит очередь: чтение отдаёт известное значение (getHTML() — входящий value, getText() и getSelectedText() — "", isEmpty() судит по входящему значению по тому же правилу, что после монтажа; свой Блок до монтажа — всегда содержимое: атом ли он, знает только его Часть для движка), запись и фокус ничего не делают, вставка возвращает false, canUndo() и canRedo() — false. На сервере ref.current пуст, пока Редактор не смонтируется, — пишите ref.current?.….

HTML-режим. getText() применяет правку поля HTML, как getHTML(); вставка применяет её, вставляет в Документ и перезаписывает поле; focus и blur относятся к полю HTML; undo и redo молчат (у textarea своя отмена), getSelectedText() отдаёт "".

Своя панель: useRichTextEditorState

useRichTextEditorState(ref, selector) — хук-селектор поверх useEditorState Tiptap. selector получает editor и возвращает срез состояния; компонент перерисовывается, только когда срез изменился (сравнение по значению, поэтому можно вернуть объект), а набор текста, не меняющий срез, панель не трогает. Пока движка нет — до монтажа и на сервере — хук возвращает null, а селектор не вызывается; когда движок появится, экран перерисуется сам.

Команды панели — через ref.current.editor.chain().focus()…run() — тот же язык, что у команд Инструментов. Своего реестра команд у кита нет.

import { useRef } from "react";
import { RichTextEditor, useRichTextEditorState } from "@olddevs/ui/editor";
import type { RichTextEditorHandle } from "@olddevs/ui/editor";

function Screen() {
  const ref = useRef<RichTextEditorHandle>(null);
  const state = useRichTextEditorState(ref, (editor) => ({
    bold: editor.isActive("bold"),
    // Без Инструмента `history` команды `undo` у движка нет.
    canUndo: editor.can().undo?.() === true,
  }));

  return (
    <>
      <button
        type="button"
        aria-pressed={state?.bold ?? false}
        // Клик в панель не должен уводить фокус из Документа.
        onMouseDown={(event) => event.preventDefault()}
        onClick={() => ref.current?.editor?.chain().focus().toggleBold().run()}
      >
        Жирный
      </button>
      <button
        type="button"
        disabled={!state?.canUndo}
        onMouseDown={(event) => event.preventDefault()}
        onClick={() => ref.current?.undo()}
      >
        Отменить
      </button>
      <RichTextEditor ref={ref} aria-label="Текст" />
    </>
  );
}

Рецепт: кнопки внешней панели делают preventDefault на mousedown. Клик по кнопке переводит фокус на неё, поле теряет фокус, и команда без .focus() в цепочке пошла бы мимо. preventDefault на mousedown оставляет фокус в Документе, поэтому выделение не пропадает ни визуально, ни для плавающей Панели и меню выделения, которые прячутся при потере фокуса. Выделение ProseMirror переживает потерю фокуса и без этого, но видимость плавающей Панели, меню выделения и панели картинки от фокуса зависит. Правило «клик в свою панель не снимает выделение» публичным контрактом не становится: это рецепт, а не свойство поля. Кнопка, открывающая свой диалог, где фокус уйдёт неизбежно, вызывает ref.current?.focus("restore") при закрытии диалога.

История с внешней панелью. Хэндл даёт undo() и redo() и их доступность. Для disabled кнопки «Отменить» берите доступность из среза хука — editor.can().undo?.() === true: срез перерисует панель сразу после правки. ref.current.canUndo() — разовое чтение в обработчике, на перерисовку оно не подписано. Без Инструмента history команды undo у движка нет (отсюда ?.), и обе кнопки остаются недоступными.

События

Экран узнаёт о Редакторе колбэк-пропсами; шины событий нет. Решение — ADR-0058, раздел 9. onChange и onUploadImage не менялись.

<RichTextEditor
  ref={ref}
  aria-label="Письмо"
  value={html}
  onChange={setHtml}
  onReady={() => ref.current?.insertHTML("<p>Здравствуйте!</p>")}
  onFocus={() => setEditing(true)}
  onBlur={() => save(ref.current?.getHTML() ?? "")}
/>
  • onReady() — без аргументов, один раз за монтаж, когда движок создан. ref к этому моменту уже действует: в колбэке можно вставить шаблон или поставить фокус. Срабатывает и при readOnly, и при disabled; перерисовка, смена placeholder или самого колбэка повторного вызова не дают. Ошибка набора tools (движка нет, см. «Свой Инструмент») и серверный рендер onReady не вызывают. Готовность — движок создан и все загрузчики Инструментов отработали (раздел «Свой Инструмент»): onReady ждёт их, а при сбое загрузчика не вызывается — и после «Повторить» приходит один раз, когда движок создан.
  • onFocus() и onBlur() — о Фокусе поля, без аргументов (DOM-событие не пробрасывается). Поле — целиком: Документ, Панель, поповеры и окна кита (ссылка, цвет, картинка, «Вставить таблицу»), поле HTML-режима. Переход между ними фокус поля не теряет: из текста в Панель, в поповер ссылки и обратно ни onBlur, ни onFocus не приходят — как у составного поля формы. Фокус Документа (focus и blur движка) — другое: клик в Панель его снимает, а Фокус поля остаётся.
  • Перед onBlur отложенный onChange выталкивается, откуда бы фокус ни ушёл (из Документа, Панели, поповера), поэтому автосохранение по onBlur получает свежий Документ. onBlur приходит на такт позже ухода фокуса: за это время он мог вернуться в поле. Если Редактор размонтирован с фокусом внутри, onBlur не вызывается (отложенный onChange при этом всё равно отдаётся).

Порядок вставки и drop

Во вставку из буфера и drop вмешивается только Часть для движка своего Инструмента — плагинами ProseMirror transformPastedHTML, transformPasted, handlePaste и handleDrop в engine. Своих имён хуков и пропсов onPaste и onDrop у Редактора нет.

  • Обработчики своих Инструментов видят событие раньше встроенных — вставки Markdown, загрузки картинок и Раскладки, — где бы свой Инструмент ни стоял в tools. handlePaste и handleDrop, вернувшие true, забирают событие целиком: встроенные до него не доходят. Так ссылку на видео можно превратить в свой Блок до того, как её разберёт Markdown.
  • Разбор по Схеме и предел действуют всегда. Что вставка внесла сверх Схемы (<script>, onclick, чужие теги, значения вне правил атрибутов), отбрасывается, как у входящего value; очистка HTML (transformPastedHTML) идёт до Схемы и ослабить её не может. Транзакция своего обработчика, которая уводит текст за maxLength, отклоняется целиком; урезание хвоста есть только у встроенной вставки из буфера (транзакция с меткой paste).
  • Файлы, кроме картинок, ловит свой Инструмент в handlePaste и handleDrop: своя функция загрузки, свой адрес — опциями его расширения. Общего onUploadFile у Редактора нет. Файл, которого никто не забрал, доходит до кита: картинка загружается через onUploadImage, остальное получает нынешнее сообщение «Файл имя — не картинка» (editor.imageNotImage в каталоге).
const PdfDrop = Extension.create({
  name: "acme_pdfDrop",
  addProseMirrorPlugins: () => [
    new Plugin({
      key: new PluginKey("acme_pdfDrop"),
      props: {
        handlePaste: (_view, event) => takePdf(event.clipboardData?.files),
        handleDrop: (_view, event) => takePdf(event.dataTransfer?.files),
      },
    }),
  ],
});

// Инструмент без Блока: только поведение.
export const pdfTool: CustomTool = { name: "acme_pdfDrop", engine: [PdfDrop] };

Public API

  • value + onChange(html) либо defaultValue — как у остальных полей (conventions.md, «Управление состоянием»). Пустой документ — "", а не <p></p>.
  • onChange вызывается не чаще раза в changeDelay мс (по умолчанию 300), по заднему фронту, и сразу — на blur, перед submit формы и при сборке new FormData(form). Сериализация документа в HTML — проход по всему дереву, и на каждую букву она тормозила бы ввод на длинных документах. changeDelay={0} — отдавать каждую правку.
  • Входящий value, равный последнему отданному HTML, документ не пересоздаёт и курсор не сбивает: обычный цикл «отдал — получил обратно» безопасен. Другой value заменяет документ целиком; такая замена — не правка, и onChange на неё не зовётся.
  • name рендерит <input type="hidden"> с текущим HTML — значение попадает в FormData и в отправку нативной формы.
  • maxLength — предел символов: текст Документа без разметки и без разделителей Блоков, атом весит длину своего Текста атома (разделитель — 0, мягкий перенос — 1, картинка — длину подписи; см. «Свой Инструмент»). Счётчик остатка появляется у 80 % предела и входит в описание поля; ввод сверх предела не проходит, вставка обрезается до того, что поместилось: хвост срезается по символам, а атом, которому не хватило места, уходит целиком вместе со всем, что после него. value длиннее предела грузится целиком: это данные владельца, их можно сокращать, но не удлинять, а счётчик говорит, на сколько превышен лимит.
  • readOnly — движок жив, текст выделяется и уходит в форму, правка закрыта. disabled — как у прочих полей: правка закрыта, поле приглушено, в форму не уходит.
  • <fieldset disabled> выключает редактор, как нативное поле — и Form pending, и чужой fieldset потребителя. Переключение на лету подхватывается (MutationObserver на атрибуте disabled предков-fieldset), без перемонтажа. Читается DOM, а не контекст Form: так покрываются оба случая одним правилом.
  • placeholder — по умолчанию из каталога (editor.placeholder).
  • onReady(), onFocus(), onBlur() — события поля, без аргументов; см. «События». onReady — один раз за монтаж, onFocus и onBlur — о Фокусе поля целиком (Документ, Панель, поповеры, поле HTML), перед onBlur выталкивается отложенный onChange.
  • onUploadImage(file) → Promise<string> — загрузчик картинок, см. «Картинки». Без него файлы не принимаются.
  • maxHeight — предел высоты области Документа (число — пиксели, строка — CSS-длина), дальше Документ прокручивается внутри рамки; resizable — ручка высоты у нижнего края области (ползунок с клавиатуры), maxHeight с ней — стартовая высота. См. «Высота поля».
  • surface — "theme" (по умолчанию) или "paper": светлый лист в любой теме, см. «Поверхность». Тип — RichTextSurface.
  • status — React-узел Строки статуса внутри рамки поля, у начала строки счётчика; кит её не озвучивает. Команда движка showNotice({ tone, text } | null) — Сообщение поля. Оба — в «Строка статуса и Сообщение поля»; подчиняются semver кита.
  • tools — имена кита и описания своих Инструментов (CustomTool, в том числе их buttons, insertItems, blockActions и callouts), пресеты массивами — RICH_TEXT_PRESETS; см. «Инструменты и пресеты» и «Свой Инструмент». Описание с ошибкой — ошибка поля.
  • ref (RichTextEditorHandle): getHTML() — текущий HTML сейчас, без ожидания задержки и без onChange (в HTML-режиме сначала применяет правку поля — тогда onChange зовётся, если Документ изменился; то же у getMarkdown()); getMarkdown() и setMarkdown(md) — см. «Markdown»; clear() — очистить документ, владелец узнаёт об этом через onChange(""); focus(position?), blur(), insertHTML, insertText, getText, getSelectedText, isEmpty, undo, redo, canUndo, canRedo — см. «Хэндл и своя панель экрана»; editor — сырой Editor Tiptap, его API следует мажору Tiptap, до монтажа и на сервере он null. focus() без аргумента, как и раньше, — каретка в конец документа (в HTML-режиме — поле HTML).
  • useRichTextEditorState(ref, selector) — хук для своей панели экрана, см. «Хэндл и своя панель экрана».
  • SSR: движок создаётся только в браузере (immediatelyRender: false). На сервере рисуется Скелет без Документа — рамка и место Панели — и скрытое поле с входящим значением. После гидратации Скелет показывает Документ, очищенный Схемой, а редактируемая область встаёт на его место после загрузчиков, без прыжка высоты.

Markdown

Движок Markdown — пакет @tiptap/markdown (тот же диапазон версий, что у остального Tiptap, в команде установки выше) и marked (^17, как у него в зависимостях): у каждого Редактора свой экземпляр marked, поэтому набор tools одного не меняет разбор Markdown у другого на той же странице.

  • Вставка. Обычный текст из буфера, в котором есть признаки Markdown — заголовок #, пункт списка (в том числе - [ ]), цитата >, ограждение кода, строка-разделитель таблицы, **жирный**, ~~зачёркнутый~~, `код`, ссылка ([текст](url), с заголовком [текст](url "заголовок"), автоссылка <https://…> и строка определения сноски [1]: https://…), *курсив* и _курсив_ на границах слов, — превращается в Блоки и Метки. Текст без этих признаков вставляется абзацами, как раньше: «6 = 2 * 3» или file_name_v2 разметкой не станут. Одна строка с разметкой продолжает абзац, а не рвёт его.
  • Ссылки. Понимаются все формы ссылки Markdown — в строке, с заголовком, сноской ([текст][1], [текст][], [текст] и строка определения [1]: адрес) и автоссылкой в угловых скобках; так же и в setMarkdown. У ссылки в Схеме только адрес: заголовок отбрасывается, строка определения в Документ не попадает, угловые скобки тоже, а getMarkdown() пишет любую из них обычной ссылкой [текст](адрес). Адрес проходит ту же политику, что и в поповере: относительный (/docs, #part) ссылкой не станет — останется её текст.
  • Копия из редактора кода. VS Code, JetBrains IDE, Sublime Text и подобные кладут в буфер рядом с текстом HTML — тот же исходник, раскрашенный подсветкой. Если этот HTML — один моноширинный блок (div или pre с моноширинным шрифтом), внутри только строки и раскраска span, его текст совпадает с обычным текстом буфера с точностью до пробелов, а обычный текст похож на Markdown по признакам выше, — разбирается обычный текст как Markdown. Цвета подсветки в Документ не попадают: это тема редактора, а не цвет автора. Код, не похожий на Markdown, вставляется как раньше.
  • Когда вставка остаётся текстом. Если рядом с текстом в буфере лежит любой другой HTML (копия из браузера, из Word, из Google Docs), работает прежний разбор HTML — Markdown в нём не ищется. Вставка «как простой текст» (Ctrl/⌘+Shift+V) и вставка внутрь кода тоже дают исходные символы. Если из вставленного Markdown не осталось ничего, что Схема может принять (при выключенном table — одна таблица, при выключенном codeBlock — один блок кода), вставляется исходный текст, а не пустое место.
  • Таблицы. Таблица GFM (| a | b | и строка-разделитель, для распознавания вставки — не короче --- в ячейке) становится таблицей, первая строка — заголовком; getMarkdown() выгружает таблицу обратно, всегда со строкой-заголовком (без неё — с пустой). Многострочное содержимое ячейки в Markdown уходит одной строкой, а переносы — <br>; | в ячейке экранируется. Выравнивание столбца ходит в обе стороны: :---: и ---: при вставке и setMarkdown становятся data-align на ячейках столбца, а getMarkdown() пишет их обратно; :--- — умолчание, атрибута не даёт. Слияние ячеек Markdown не выражает: getMarkdown() раскладывает слитую ячейку по сетке — текст остаётся в её левом верхнем месте, накрытые места становятся пустыми ячейками, поэтому столбцы ниже не съезжают; само слияние теряется, и setMarkdown(getMarkdown()) вернёт таблицу без него.
  • getMarkdown() — документ как Markdown, сейчас, без ожидания changeDelay. Пустой документ и вызов до монтажа (на сервере движка нет) — "".
  • setMarkdown(md) заменяет документ разобранным Markdown. Как clear(), это замена снаружи: onChange получает новый HTML сразу и один раз, предел maxLength её не режет. До монтажа ничего не делает.
  • Схема главнее. Разобранное проходит через Схему редактора, а не мимо неё: всё, чего в ней сейчас нет, отбрасывается, в том числе из setMarkdown. В этом выпуске в потерях картинки с адресом вне http/https (пропадают целиком, вместе с alt), ссылки с адресом вне политики http/https/mailto (текст ссылки остаётся) и «сырой» HTML внутри Markdown (он пропадает целиком); а без инструмента taskList чек-листы становятся обычными пунктами списка, без codeBlock блок кода пропадает, без table пропадает таблица. Раскладки и Обёртки без тона из Markdown не бывает: их Блоки встают подряд. Обёртку с тоном даёт только GitHub alert (> [!NOTE] и ещё четыре метки, раздел «Обёртка», «Тон»), и getMarkdown() выгружает её им же. С heading1 # даёт H1, а getMarkdown пишет H1 как #. Без него # прижимается к H2; ##### в любом случае — к H4. Что именно принимается, определяет набор инструментов, а не этот список.
  • Свои Инструменты. Свои правила Markdown — в Части для движка; без них свой Блок выгружается содержимым, атом — Текстом атома (раздел «Свой Инструмент», «Вне Редактора»).
  • Что теряется при выгрузке. Markdown не выражает цвет текста и маркера, выравнивание Блока, подчёркивание, ширину и подпись картинки, слияние ячеек таблицы и список внутри ячейки: картинка выгружается как ![alt](адрес), getMarkdown() остальное опускает, а setMarkdown(getMarkdown()) вернёт документ без них. Это свойство формата, а не дефект — для хранения без потерь берите HTML.

Схема и безопасность

Схема — единственный источник допустимой разметки. Всё, чего в ней нет, отбрасывается и при загрузке value, и при вставке: <script>, обработчики on*, атрибут style (кроме цвета автора, ниже), ссылки на javascript:, data: и любые адреса, кроме абсолютных http, https и mailto, картинки с адресом вне http/https (data: — base64 — и blob: в том числе), width, height и class на <img>, неразрешённые теги. Текст из отброшенной обёртки сохраняется, теряется сама обёртка.

Белый список этого выпуска при preset="full" (выключенные инструменты сужают его):

ЧтоРазрешено
Блокиp, h1, h2, h3, h4, ul, ol, li, blockquote, hr, pre, table, tbody, tr, th, td; label, input, span — только внутри пункта чек-листа; div — там же, а ещё Раскладка и её колонки (Раскладка — только на верхнем уровне, колонка — только прямо в ней) и Обёртка (на верхнем уровне и прямо в колонке, не в другой Обёртке); figure — картинка, внутри неё img и необязательная figcaption (вне figure их нет)
Меткиstrong, em, u, s, code, sup, sub, mark (только с цветом), br, a
Атрибутыol[start] — только когда нумерация не с 1; ul[data-type], li[data-type], li[data-checked] — чек-лист; input[type], input[checked] — его флажок; code[class] — язык блока кода, только «language-python», «language-typescript», «language-javascript», «language-go», «language-html», «language-json», «language-yaml» или «language-markdown» (без языка класса нет); span[data-color], mark[data-color] — цвет текста и маркер, только имена своей палитры — на span цвета текста, на mark маркера; span[style], mark[style] — произвольный цвет автора, только «color:#rrggbb» и «background-color:#rrggbb», и не вместе с data-color; p[data-align], h1[data-align], h2[data-align], h3[data-align], h4[data-align] — выравнивание Блока, по центру или справа; th[colspan], td[colspan] — слияние по горизонтали, целое от 2 до 20; th[rowspan], td[rowspan] — по вертикали, от 2 до 50; th[data-align], td[data-align] — выравнивание столбца, по центру или справа, на каждой его ячейке; a[href] — только http, https или mailto; a[rel] — всегда «noopener noreferrer»; img[src] — только абсолютный http или https, без data и blob; img[alt] — всегда, пустой у картинки без описания; img[data-width] — 50, 75 или 100; div[data-block] — Раскладка («layout»), её колонка («column») и Обёртка («wrapper»); div[data-layout] — вариант Раскладки, доли колонок в процентах: «100», «50-50», «33-33-33», «25-25-25-25», «60-40» или «40-60»; div[data-tone] — тон Обёртки, только на ней: «neutral», «accent», «success», «warning» или «danger» (без тона атрибута нет)

Пункт чек-листа Tiptap пишет так: <li data-type="taskItem" data-checked="true"><label><input type="checkbox" checked><span></span></label><div><p>…</p></div></li>. Санитайзеру стоит пускать input, label, span и div только внутри такого пункта, а input — только с type="checkbox".

Таблица — table > tbody > tr > th|td; у ячейки — только colspan и rowspan (целое от 2 до 20 и до 50) и data-align (center или right), ни style, ни colwidth. thead, tfoot, colgroup и caption в Схеме нет и Редактор их не пишет (строка-заголовок — первая строка из th); tbody, tr, th и td вне таблицы парсер HTML выбрасывает сам, поэтому хук для них не нужен.

Других классов, style (кроме цвета автора), aria-* и data-* (кроме пяти имён Схемы: data-type, data-checked, data-color, data-align, data-width) в отдаваемом HTML нет: типографика живёт на контейнере, а не на узлах Документа.

style — узкое исключение. Цвет автора пишется как <span style="color:#rrggbb"> и <mark style="background-color:#rrggbb"> и больше ничего в style не бывает: ни другие свойства, ни другие теги, ни другие формы цвета. ALLOWED_ATTR у DOMPurify общий на все теги и значений не проверяет, поэтому в голом richTextSanitizeConfig() style нет вовсе, а пускает его только sanitizeRichText (RichTextView), сверив тег и значение с RICH_TEXT_SCHEMA.customColor.styles.

Это не санитизация. Клиентская очистка от XSS не защищает: запрос в обход интерфейса принесёт что угодно. HTML от пользователя чистит сервер потребителя — sanitizeRichText(html, DOMPurify) по белому списку выше. Список растёт вместе с набором Блоков, и хелпер — вместе с ним; он и закрытый конфиг richTextSanitizeConfig() описаны на странице RichTextView. Таблица выше повторяет src/editor/schema.ts и проверяется тестом. Скелет до Готовности показывает value, уже разобранный Схемой в браузере (раздел «Свой Инструмент»), а на сервере Документа не рисует, — так что Редактор, в отличие от RichTextView, неочищенный HTML в страницу не кладёт. Серверной очистки это не отменяет: значение уходит дальше в базу и в RichTextView.

Доступность и форма

  • Редактируемая область — role="textbox" с aria-multiline.
  • Панель — role="toolbar" с именем из каталога (editor.toolbar, «Форматирование») и одной остановкой Tab: Tab входит в Панель, стрелки влево и вправо идут по кнопкам, Home и End — к краям, следующий Tab уводит в документ. Панель запоминает, где был фокус. Собрана на Toolbar кита.
  • Кнопки — значки с aria-label; кнопки-переключатели (жирный, курсив, подчёркнутый и маркер в плашке, маркер на Панели, ссылка, картинка) объявляют состояние aria-pressed — скринридер слышит, включён ли курсив под курсором. Разовые действия (отмена, повтор) — обычные кнопки. Кнопка своего Инструмента объявляет aria-pressed только если у неё есть isActive, а её сочетание — в подсказке и aria-keyshortcuts; пустая подпись заменяется именем Инструмента. Недоступное сейчас действие — aria-disabled, а не disabled: оно остаётся в обходе стрелками.
  • Подсказка на наведение и фокус называет действие и сочетание; сочетание же объявлено aria-keyshortcuts (Control+B или Meta+B на Apple). На Apple подпись — ⌘+⇧+S, у остальных — Ctrl+Shift+S, тем же признаком, по которому движок решает, какая клавиша mod.
  • Меню вставки по / — паттерн комбобокса APG: фокус остаётся в поле, список — role="listbox" с разделами role="group", а поле указывает на него aria-controls и на текущую строку — aria-activedescendant (у строки aria-selected). Поле объявляет aria-autocomplete="list". aria-expanded на поле не ставится: ARIA 1.2 не разрешает его role="textbox", а менять роль многострочного документа на combobox нельзя. Имя строки — её подпись, подсказка ## скринридеру не читается.
  • Список по / — слой Popover кита (Radix): Escape закрывает только его, а не Dialog или Drawer вокруг поля, и набранное остаётся текстом. «+» — Menu кита с aria-haspopup, Escape в нём ведёт себя так же (ADR-0041).
  • Справочник сочетаний — Popover кита: кнопка с aria-haspopup="dialog" и aria-expanded, поповер — role="dialog" с именем «Сочетания клавиш», внутри table с заголовками столбцов (columnheader) и заголовком строки (rowheader) у каждой команды, сочетание — <kbd>. Escape и щелчок снаружи закрывают поповер и возвращают фокус на кнопку; в Dialog — только поповер (ADR-0041). Тесты: tests/rich-text-editor-shortcuts.test.tsx, в браузере — tests-visual/interactions.spec.ts («RichTextEditor inside Dialog: Escape closes the shortcuts reference»).
  • Плавающая Панель — та же role="toolbar" с одной остановкой Tab и теми же стрелками; появляется над непустым выделением, на сенсорном экране заменяется фиксированной. Из документа в любую Панель — Alt+F10 (раздел «Клавиатура: Alt+F10»).
  • Поповер ссылки — role="dialog" с именем «Ссылка» (editor.link); поле подписано (editor.linkAddress), ошибка адреса (editor.linkInvalid) связана с полем через aria-describedby и ставит aria-invalid. Кнопка «Ссылка» — переключатель с aria-pressed, когда каретка в ссылке.
  • Картинка. Поповер — role="dialog" с именем «Картинка» (editor.image); поля «Адрес картинки» и «Альтернативный текст» подписаны, у второго подсказка связана через aria-describedby, ошибка адреса (editor.imageInvalid) ставит aria-invalid. Пресеты ширины — role="group" «Ширина» из кнопок-переключателей с aria-pressed. Панель выделенной картинки — role="toolbar" «Картинка» с одной остановкой Tab. Ручки ширины — только для мыши (aria-hidden, без фокуса): та же ширина есть в поповере и на панели. Ход загрузки — живой регион role="status" вне документа (живые регионы внутри contenteditable читаются не везде), отказ — видимая строка role="alert" под полем: это Сообщение поля, оно же у showNotice.
  • Меню «¶» — Menu кита: пункты-радио (menuitemradio) с отметкой aria-checked у текущего типа Блока, сочетания подписаны в пунктах. Enter, пробел и ↓ на кнопке открывают меню, стрелки ходят по пунктам. Escape закрывает меню и возвращает фокус на кнопку; внутри Dialog и Drawer — только меню, не окно (ADR-0041). Имя кнопки включает текущий тип: «Тип блока: Текст»; меню названо своей кнопкой. Состояние в меню, а не кнопками-переключателями в ряд: пункт с отметкой объявляет его так же, как aria-pressed, а значение на кнопке слышно без открытия — ADR-0054.
  • Меню «…» — Menu кита с кнопкой-значком «Ещё форматирование»: Метки и «Блок кода» — пункты-флажки (menuitemcheckbox) с aria-checked, сочетания подписаны в пунктах. Цвет текста и маркер — подменю (SubmenuTrigger, как у меню «Таблица»): пункты-радио (menuitemradio) названы словом («Красный»; у маркера — «Жёлтый», «Зелёный», «Розовый»), а не только окрашены, текущий отмечен, за палитрой — «снять» и «Свой цвет…». Имя пункта подменю включает текущий цвет: «Цвет текста: Красный», «Цвет текста: Свой #00aa55», «Маркер: Без маркера». → открывает подменю, ← и Escape закрывают по одному уровню; внутри Dialog и Drawer пункты выбираются и мышью, и клавиатурой, а Escape не закрывает окно, пока открыто меню (ADR-0041).
  • Поповер своего цвета — role="dialog" с именем «Свой цвет текста» или «Свой цвет маркера»; фокус при открытии — на кнопке выбора цвета, чьё видимое имя называет значение («Цвет #d92d20») и меняется вместе с ним. Предупреждение о контрасте — role="status": скринридер объявляет его появление. Escape закрывает один слой за раз (ADR-0041): выбор цвета, затем поповер, затем Dialog или Drawer вокруг поля; фокус возвращается в документ.
  • Меню «Выравнивание» и «Списки» — как «¶»: кнопка с aria-haspopup="menu" и текущим значением в имени («Выравнивание: по центру», «Список: нумерованный»), пункты-радио с aria-checked, сочетания подписаны в пунктах; Enter, пробел и ↓ открывают, Escape закрывает только меню и возвращает фокус на кнопку. Прежде выравнивание было тремя кнопками-переключателями, а списки — тремя кнопками рядом: значение, слышимое в имени кнопки, и отметка в меню объявляют то же, что aria-pressed, а ряд короче на пять кнопок — ADR-0054.
  • Меню «Таблица» — Menu кита с подменю (SubmenuTrigger): кнопка с aria-haspopup="menu", пункты с › — aria-haspopup и aria-expanded, «Строка-заголовок» — menuitemcheckbox, выравнивание столбца — menuitemradio, недоступные пункты — aria-disabled и остаются в обходе стрелками. Подменю «Таблица ›» — поповер react-aria с сеткой (шаблон grid APG, aria-activedescendant, размер словами в живой области). Escape закрывает по одному уровню, в Dialog окно — последним (ADR-0041). Окно «Вставить таблицу» — Dialog кита: фокус при открытии — в поле «Столбцы», при закрытии — обратно в документ. Тесты: tests/rich-text-editor-table-menu.test.tsx, в браузере — tests-visual/rich-text-editor-table-menu.spec.ts.
  • Эмодзи. Кнопка — aria-haspopup="dialog"; поповер — role="dialog" с именем «Эмодзи» (editor.emoji) и сеткой role="grid" («Выберите эмодзи»), в которой фокус остаётся на сетке, а текущую ячейку называет aria-activedescendant, как у сетки размера таблицы. Категории — rowgroup с именем («Избранное», «Смайлы»…), эмодзи — gridcell с именем из каталога (editor.emojiName: «Огонь», «Красное сердце»), а не символом: скринридеры озвучивают символы по-разному, а иногда молчат. Символ в ячейке скрыт от скринридера. Escape закрывает поповер и возвращает фокус в Документ.
  • Раскладка. Колонки идут в разметке слева направо — в том же порядке их читает скринридер и в том же порядке они встают столбцом на узком экране. Своей роли у колонки нет: это группа Блоков Документа, а не виджет. Tab, который Документ взял (колонки, список, таблица), не уходит к ловушке фокуса Dialog и Drawer вокруг поля; отпущенный Tab (крайняя колонка, абзац вне списка) переводит фокус дальше — ловушки клавиатуры нет.
  • Обёртка. Своей роли у неё нет: это группа Блоков Документа, а не виджет, и скринридер читает её Блоки по порядку, как Блоки вокруг. Tab в Обёртке не перехватывается — ловушки клавиатуры нет; из Обёртки выводят Enter в пустом последнем абзаце и стрелки у её края. Тон роли и aria-* не добавляет — он только оформление: смысл заметки («Внимание: …») автор пишет словом (WCAG 1.4.1). Подменю «Тон ›» — Menu кита: пункты-радио с aria-checked, имя пункта подменю — с текущим тоном.
  • HTML-режим. Кнопка «HTML» — переключатель с aria-pressed, в обходе стрелками Панели, как любая кнопка. Поле — нативный textarea с именем из каталога («HTML-код документа», editor.sourceField); подсказка и ошибка FormField уходят в его aria-describedby тем же слиянием, что у Документа, aria-invalid зеркалит Документ. Автоисправления выключены: spellcheck="false", autocapitalize, autocorrect и autocomplete — off, dir="ltr". Tab и Shift+Tab — нативные: табуляцию поле не вставляет, ловушки клавиатуры нет; отмена внутри поля — обычная отмена текстового поля. Вход ставит фокус в поле (каретка в начале), выход кнопкой — в Документ (каретка в конце). Alt+F10 из поля — на кнопку «HTML», как из Документа на Панель: Панель объявляет это сочетание и в режиме. Остальные кнопки Панели в режиме — aria-disabled, в обходе стрелками. Щелчок по подписи FormField и отказ required ведут фокус в поле HTML. Тесты: tests/rich-text-editor-source.test.tsx, в браузере — tests-visual/rich-text-editor-source.spec.ts.
  • Сообщение поля и Строка статуса. Живой регион role="status" стоит в каждом Редакторе и пуст, пока нет сообщения status; ошибка — role="alert" под полем. Строка статуса — контейнер без role и aria-live: кит её не озвучивает, «Сохранено» экран оборачивает в role="status" сам. Строка видна при readOnly и disabled, счётчик остаётся в aria-describedby. Тесты: tests/rich-text-editor-notice.test.tsx; в витрине — StatusLine, StatusLineReadOnly, FieldNotice.
  • Карточка у текста. role="dialog" без aria-modal, имя — подпись записи (label); фокус при открытии остаётся в Документе. О появлении объявляет живой регион role="status" фразой editor.calloutShown с подписью и сочетанием Alt+F10; Alt+F10 ведёт в Карточку, Escape возвращает каретку и прячет её. Раздел «Карточка у текста».
  • Флажок пункта чек-листа назван из каталога (editor.taskCheckbox): «Выполнено: <текст пункта>».
  • Имя. <label for> браузер связывает только с нативными полями, а contenteditable к ним не относится. Поэтому FormField публикует id своей подписи контекстом, а RichTextEditor внутри него ссылается на неё через aria-labelledby и ставит фокус в документ по щелчку на подписи. id у редактора и htmlFor у FormField для этого не нужны (но htmlFor проставляйте по привычке — он ничему не мешает). DOM подписи редактор не правит, только слушает щелчок. Явные aria-label и aria-labelledby сильнее подписи. Без всего этого имя берётся из каталога (editor.label) — безымянного поля не бывает, но такое имя ничего не говорит о содержимом: давайте своё.
  • Подсказка и ошибка FormField объявляются вместе с полем: их id уходит в aria-describedby и сливается со своим описанием из пропа. Ошибка ставит aria-invalid и красит рамку; явный проп aria-invalid сильнее.
  • required — как нативное. Пустой документ (нет ни текста, ни атома — hr, картинки, своего атома, — ни таблицы) блокирует отправку обычной <form>: браузер показывает пузырь с сообщением из каталога (editor.required, следует локали кита), а фокус уходит в редактор. Как только в документе появилось содержимое, блок снят — сразу, не дожидаясь changeDelay. В HTML-режиме пустоту решает текст поля HTML, разобранный Схемой, — тоже сразу, на наборе (раздел «HTML-режим»). readOnly и disabled не проверяются, как у нативных полей. Звёздочка у подписи — по-прежнему только FormField required.
  • Как это устроено: скрытое <input type="hidden" name> из проверки ограничений исключено спецификацией, а contenteditable не form-associated, поэтому рядом с ним стоит невидимое текстовое поле без name — оно несёт setCustomValidity, а в FormData значение по-прежнему кладёт скрытое.
  • В kit Form (noValidate) браузер не проверяет ни одно поле — обязательность там выражает потребитель через FormField error, как и для TextControl.
  • readOnly и disabled объявляются aria-readonly и aria-disabled.
  • Все строки — из каталога editor.*, с русской локалью из @olddevs/ui/locales/ru. Названия клавиш в подсказках (Ctrl, Shift) не переводятся: это надписи на клавишах.

Ручной прогон — вне CI, перед выпуском заметных изменений: VoiceOver на macOS и iOS, NVDA, ввод через IME (японский и китайский), Android Chrome с Gboard, вставка из Word и Google Docs.

Антипаттерны

  • Хранить и показывать HTML редактора без санитизации на сервере.
  • Хранить Markdown из getMarkdown() как основное значение: цвет, маркер, выравнивание и подчёркивание в нём теряются.
  • Разрешать style на сервере в обход sanitizeRichText (например, дописав его в ALLOWED_ATTR голого конфига): Редактор пишет его только как цвет автора (color:#rrggbb на span, background-color:#rrggbb на mark), а любой другой CSS пришёл бы не от него.
  • Брать цвет автора там, где документ показывается в нескольких темах: он не следует за темой, в отличие от палитры.
  • Ставить changeDelay={0} «на всякий случай» на длинных документах: форма и так получает свежий HTML перед отправкой.
  • Ставить ссылку командой ref.editor в обход поповера с относительным адресом или чужой схемой: в HTML такая ссылка уйдёт без href.
  • Класть Редактор с toolbar="floating" или "bubble" в контейнер с overflow: hidden вплотную к его верхнему краю: Панель или плашка над первой строкой срежется.
  • Перекрашивать всплывающие поверхности Редактора сплошным цветом через className или CSS потребителя: стекло снимается системной настройкой прозрачности и контраста, а ручная подложка эту настройку перестаёт слушать.
  • Красить плашку меню выделения своими цветами поверх токенов: инверсная пара проверена на контраст во всех темах и брендах, а ручной цвет — нет. Свой бренд меняет --accent-on-inverse в своём блоке, как --accent-strong.
  • Звать методы ref.editor как часть контракта кита — команды Tiptap следуют мажорам движка; частые задачи закрывает Хэндл (insertHTML, getText, focus, undo), а editor — выход для команд Меток, Блоков и своих Инструментов. Исключение — showNotice: она команда кита и подчиняется semver.
  • Делать кнопки своей панели без preventDefault на mousedown: клик уводит фокус из поля, и плавающая Панель вместе с меню выделения исчезают.
  • Считывать ref.current.editor.isActive(...) в рендере своей панели вместо useRichTextEditorState: панель не узнает о смене выделения и не перерисуется.
  • Оборачивать всю Строку статуса в role="status": число слов зачитывалось бы на каждую правку. Озвучивайте только то, что нужно (Сохранено).
  • Рисовать свою строку ошибки под полем рядом с полем для ошибок вставки и загрузки: showNotice — одно место с role="alert", и своя копия будет озвучена дважды.
  • Заворачивать в RichTextEditor короткие подписи и заголовки: это поле для документа, а не для строки.
  • Прятать кнопку стилем, оставив инструмент в tools: Схема его по-прежнему пропустит — из вставки и по сочетанию. Выключайте инструмент, а не кнопку.
  • Менять tools у смонтированного поля в ожидании, что Схема перестроится: набор читается при создании движка.
  • Передавать смысл заметки одним тоном Обёртки: подложку не видит скринридер и не различает дальтоник, а в Markdown без поддержки alerts остаётся цитата. Пишите словом — «Внимание: …» (WCAG 1.4.1).
  • Красить текст внутри Обёртки с тоном цветом палитры или своим цветом: на цветной подложке он может не держать 4.5:1 — гейт меряет основной и вторичный текст Документа, а не цвет автора.

Ссылки

  • Исходник: src/editor/RichTextEditor.tsx; Панель — src/editor/EditorToolbar.tsx, меню форматирования («¶», «…», «Выравнивание», «Списки») — src/editor/FormatMenus.tsx и src/editor/blockTypes.ts, плавающая — src/editor/FloatingToolbar.tsx, меню выделения — src/editor/SelectionBubble.tsx, общее условие показа и позиция — src/editor/selectionMenu.ts; ссылка — src/editor/link.ts, описание Инструмента src/editor/linkTool.tsx и форма адреса src/editor/LinkPopover.tsx; эмодзи — описание Инструмента src/editor/emojiTool.tsx и сетка src/editor/EmojiGrid.tsx; кнопки из описаний Инструментов и рамка их поповеров — src/editor/ToolButtonView.tsx, их сочетания — src/editor/toolButtons.ts, сочетания их пунктов вставки и Действий над Блоком — src/editor/toolEntryShortcuts.ts; картинка — src/editor/image.ts (узел, загрузка, вставка файлов), src/editor/ImagePopover.tsx, src/editor/ImageToolbar.tsx; Сообщение поля — команда src/editor/fieldNotice.ts, тексты о файлах src/editor/imageNotice.ts, живой регион, строка ошибки и Строка статуса src/editor/FieldStatus.tsx; свой Инструмент — публичная форма src/editor/customTool.ts, движок и проверка набора src/editor/customToolEngine.ts, разворот tools src/editor/builtinTools.ts; таблица — src/editor/tables.ts (узлы, слияние, выравнивание, Markdown), src/editor/tableActions.ts (команды меню, сортировка, память размера), src/editor/TableMenuButton.tsx (меню «Таблица»), src/editor/TableSizeGrid.tsx и src/editor/TableInsertDialog.tsx; Раскладка — src/editor/layout.ts (узлы, команды, клавиатура) и src/editor/columnLayouts.ts (варианты данными); Обёртка — src/editor/wrapper.ts (узел, вставка, снятие, тон, клавиатура, Markdown) и src/editor/wrapperTones.ts (тоны и метки GitHub alerts данными); Документ с контейнерами Блоков — src/editor/document.ts; Хэндл — src/editor/handle.ts, хук — src/editor/useRichTextEditorState.ts; HTML-режим — состояние и применение в src/editor/RichTextEditor.tsx, форматирование HTML — src/editor/sourceFormat.ts; инструменты и пресеты — src/editor/tools.ts; команды над Блоками — src/editor/blockCommands.ts; ручка Блока — src/editor/BlockHandle.tsx; таблица сочетаний и подписи — src/editor/shortcuts.ts, справочник — src/editor/ShortcutsButton.tsx; стекло всплывающих поверхностей — src/editor/glass.ts; Меню вставки — src/editor/insertItems.ts (реестр пунктов), src/editor/SlashMenu.tsx, src/editor/InsertMenuButton.tsx, src/editor/slashCommand.ts; Схема данными — src/editor/schema.ts; палитра и выравнивания — src/editor/palette.ts, расширения цвета — src/editor/styleExtensions.ts; цвет автора — форма и разбор src/editor/customColor.ts, контраст src/editor/contrast.ts, поповер src/editor/CustomColorPopover.tsx; типографика — src/editor/prose.ts; блок кода — src/editor/codeBlock.ts (расширение, псевдонимы, Alt+F10), src/editor/CodeBlockView.tsx (шапка: язык и «Копировать»), src/editor/CodeCopyButton.tsx (кнопка с подсказкой), src/editor/codeHeader.ts (классы шапки, общие с просмотром), лист surface="paper" — src/editor/surface.ts и src/editor/tokens.ts, src/editor/codeLanguages.ts (языки данными), src/editor/lowlight.ts (грамматики), src/editor/codeTheme.ts (тема подсветки), серверный хелпер — src/editor/highlight.ts.
  • Тесты: tests/rich-text-editor-callout.test.tsx (Карточка у текста), tests/rich-text-editor.test.tsx, tests/rich-text-editor-handle.test.tsx и tests/rich-text-editor-handle-ssr.test.tsx (Хэндл и хук), tests/rich-text-editor-toolbar.test.tsx, tests/rich-text-editor-block-menu.test.tsx, tests/rich-text-editor-more-menu.test.tsx, tests/rich-text-editor-align-list-menus.test.tsx, tests/rich-text-editor-insert-menu.test.tsx, tests/rich-text-editor-schema.test.tsx, tests/rich-text-editor-markdown.test.tsx, tests/rich-text-editor-color.test.tsx, tests/rich-text-editor-custom-color.test.tsx, tests/rich-text-editor-link.test.tsx, tests/rich-text-editor-floating.test.tsx, tests/rich-text-editor-bubble.test.tsx, tests/rich-text-editor-glass.test.tsx, tests/rich-text-editor-table.test.tsx, tests/rich-text-editor-lazy-builtins.test.tsx (ленивые блок кода и таблица: basic их не грузит, full — Скелет до чанков), tests/rich-text-editor-image.test.tsx, tests/rich-text-editor-notice.test.tsx, tests/rich-text-editor-code-block.test.tsx, tests/rich-text-editor-block-commands.test.tsx, tests/rich-text-editor-block-handle.test.tsx, tests/rich-text-editor-shortcuts.test.tsx, tests/rich-text-editor-layout.test.tsx, tests/rich-text-editor-wrapper.test.tsx, tests/rich-text-editor-wrapper-tone.test.tsx, tests/rich-text-editor-source.test.tsx, tests/rich-text-editor-custom-tool.test.tsx (свой Инструмент; тестовые Инструменты — tests/editor-test-tools.ts), tests/rich-text-editor-custom-ui.test.tsx (пункты вставки и Действия над Блоком своего Инструмента), tests/rich-text-editor-custom-button.test.tsx (кнопки своего Инструмента), tests/rich-text-editor-custom-markdown.test.tsx (Markdown своего Блока по умолчанию и по своим правилам), tests/rich-text-editor-atom-text.test.tsx (Текст атома, вес в maxLength, пустота), tests/rich-text-editor-empty-table.test.tsx (пустая таблица — содержимое), tests/rich-text-editor-max-height.test.tsx (maxHeight и resizable: что внутри области прокрутки, место ручки Блока при прокрутке), tests/rich-text-editor-events.test.tsx (onReady, onFocus, onBlur, порядок вставки и drop), tests/rich-text-editor-loaders.test.tsx (загрузчики Части для движка, «Повторить»), tests/rich-text-editor-skeleton.test.tsx (Скелет), tests/rich-text-paper.test.tsx, tests/rich-text-paper-css.test.tsx, tests/editor-entry.test.ts, tests/editor-schema-entry.test.ts, tests/editor-highlight-entry.test.ts; в браузере — tests-visual/interactions.spec.ts («RichTextEditor inside Dialog» — Escape в списке стилей, в Меню вставки, в поповере ссылки и в меню языка блока кода; «RichTextEditor inside-dialog» и «inside-drawer» — меню «…» и подменю цвета мышью и клавиатурой; «RichTextEditor insert menu»; «RichTextEditor block commands» — настоящие ⌥⇧↑/↓, ⌘], ⌘[ и ⌘⇧D), tests-visual/rich-text-editor-image.spec.ts (перетаскивание файла и ручки ширины), tests-visual/rich-text-editor-bubble.spec.ts (меню выделения в Dialog, Drawer и контейнере прокрутки, у края окна, инверсная поверхность в трёх темах), tests-visual/rich-text-editor-layout.spec.ts (Раскладка в Dialog: колонки рядом и Tab по ним, стрелки, столбец на телефоне), tests-visual/rich-text-editor-wrapper.spec.ts (Обёртка: пунктир в Редакторе и без рамки в RichTextView, тон одним видом в обоих, выход по Enter, ↑ из Обёртки), tests-visual/rich-text-editor-block-handle.spec.ts (меню ручки в Dialog: Ctrl/⌘+/, Escape по уровням; место ручки у первой строки Блока), tests-visual/rich-text-editor-source.spec.ts (HTML-режим: высота поля, рост по содержимому, фокус на входе и выходе).
  • API расширения одной картой, «Стабильность» и рецепты — API расширения Редактора.
  • Показ без правки — RichTextView.
  • Storybook: Primitives/RichTextEditor.
  • Происхождение кода: minimal-tiptap (MIT); цвета темы подсветки — highlight.js (BSD-3-Clause); лицензии — в NOTICE пакета.

На этой странице

Кратко для AIКогда использоватьКогда не использоватьУстановкаМинимальный примерПанельМеню «¶» — тип БлокаМеню «…» — More formattingМеню «Выравнивание» и «Списки»Плавающая ПанельМеню выделения: selectionMenu и toolbar="bubble"Стекло всплывающих поверхностейКлавиатура: Alt+F10Справочник сочетанийРучка Блока «⋮»Клавиатура: команды над БлокамиСсылкиИнструменты и пресетыСвой ИнструментЗагрузчик Части для движкаСкелетТекст атомаВне Редактора: сервер, Markdown и ПросмотрКнопка своего ИнструментаПункты вставки и Действия над БлокомКарточка у текстаБлок кодаКартинкиЗагрузка файлов: onUploadImageЦвет, маркер и выравниваниеСвой цветТаблицыМеню вставкиРаскладкаОбёрткаТонЭмодзиHTML-режимСтрока статуса и Сообщение поляСтрока статуса: statusСообщение поля: showNoticeВысота поля: maxHeight и resizableПоверхность: surfaceХэндл и своя панель экранаМетоды ХэндлаСвоя панель: useRichTextEditorStateСобытияПорядок вставки и dropPublic APIMarkdownСхема и безопасностьДоступность и формаАнтипаттерныСсылки