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иDrawerEscape закрывает только справочник, а не окно (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иDrawerEscape закрывает только поповер: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–4 | h2, h3, h4 | Ctrl/⌘+Alt+2…4 | ## , ### , #### | — |
heading1 | «¶»: добавляет Заголовок 1 | h1 | Ctrl/⌘+Alt+1 | # | — |
history | Отменить, Повторить | — | Ctrl/⌘+Z, +Shift+Z | — | да |
bold | Жирный | strong | Ctrl/⌘+B | **текст** | да |
italic | Курсив | em | Ctrl/⌘+I | *текст* | да |
underline | «…»: Подчёркивание | u | Ctrl/⌘+U | — | — |
strike | Зачёркнутый | s | Ctrl/⌘+Shift+S | ~~текст~~ | да |
code | «…»: Строчный код | code | Ctrl/⌘+E | `текст` | да |
superscript | «…»: Верхний индекс | sup | Ctrl/⌘+. | — | — |
subscript | «…»: Нижний индекс | sub | Ctrl/⌘+, | — | — |
clearFormatting | «…»: Очистить стили | — (снимает метки) | Ctrl/⌘+\ | — | — |
link | Ссылка (поповер адреса) | a[href] | Ctrl/⌘+K | [текст](адрес) при вставке | да |
color | «…»: Цвет текста › — имена | span[data-color], span[style] | — | — | — |
highlight | «…»: Маркер › — имена | mark[data-color], mark[style] | — | — | — |
align | Выравнивание ▾ (меню) | p, h2–h4 с data-align | Ctrl/⌘+Shift+L, +E, +R | — | — |
bulletList | Списки ▾ и «¶»: Маркированный | ul, li | Ctrl/⌘+Shift+8 | - или * | да |
orderedList | Списки ▾ и «¶»: Нумерованный | ol, li | Ctrl/⌘+Shift+7 | 1. | да |
taskList | Списки ▾ и «¶»: Чек-лист | ul[data-type=taskList] и пункты | Ctrl/⌘+Shift+9 | [ ] или [x] | — |
blockquote | «¶»: Цитата | blockquote | Ctrl/⌘+Shift+B | > | да |
codeBlock | «…»: Блок кода | pre, code class="language-*" | Ctrl/⌘+Alt+C | ``` | — |
horizontalRule | «+»: Разделитель | hr | — | --- | — |
table | Таблица ▾ (меню) | table, tbody, tr, th, td | Tab, Shift+Tab, Alt+↓ в таблице | — | — |
image | Картинка (поповер адреса) | figure, img, figcaption | — |  при вставке | — |
emoji | Эмодзи (поповер с сеткой) | — (обычный текст) | — | — | — |
source | HTML (переключатель режима) | — (правит HTML Документа) | — | — | — |
layout | «+»: Раскладка | div с data-block и data-layout | Tab, 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()отдают один и тот же Текст атома; атом попадает в выделенный текст только целиком. Атом безrenderMarkdownMarkdown выгружает его Текстом атома, а пропадает только атом без текста; свой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. Нет — видна всегда |
selectionMenu | true — кнопка стоит ещё и в Меню выделения (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 (внутриDialogEscape закрывает поповер, а не окно), имя рамки — подпись кнопки. Содержимое монтируется заново на каждое открытие.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 (внутриDialogEscape закрывает поповер, а не окно), имя рамки — подпись пункта, закрытие возвращает фокус и выделение в Документ. Выбор в «/», «+», «Добавить ›» или «Формат ›» закрывает меню и открывает поповер у каретки: из «/» — там, где стоял «/», набранная «/команда» убирается, как у обычного пункта. Сочетание пункта открывает тот же поповер у каретки. Документ кит не готовит — ни пустого абзаца, ни снятых обёрток: Блок ставит содержимое через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  при вставке. В документ картинка пишется Блоком:
<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 отменяет. ВнутриDialogEscape закрывает только поповер (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) | Цвет текста |
|---|---|---|
accent | Accent / Акцентный | accent-strong |
success | Green / Зелёный | success-strong |
warning | Amber / Жёлтый | warning-strong |
danger | Red / Красный | danger-strong |
lime | Lime / Лаймовый | draft-strong |
muted | Gray / Серый | 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) |
|---|---|---|---|---|
yellow | Yellow / Жёлтый | --marker-yellow | #ffea80 | 14.4 |
green | Green / Зелёный | --marker-green | #aaff80 | 14.4 |
pink | Pink / Розовый | --marker-pink | #ff80bf | 7.5 |
Читаемые (значения не менялись; текст на них — основной текст темы, а не тёмный):
Имя data-color | Название (en / ru) | Токен | light | dark и dim | Основной текст, худшая из тем |
|---|---|---|---|---|---|
blue | Blue / Синий | --marker-blue | #d3e5ef | #28456c | 8.93 |
gray | Gray / Серый | --marker-gray | #e3e2e0 | #5a5a5a | 6.33 |
brown | Brown / Коричневый | --marker-brown | #eee0da | #603b2c | 8.92 |
orange | Orange / Оранжевый | --marker-orange | #fadec9 | #854c1d | 6.32 |
purple | Purple / Фиолетовый | --marker-purple | #e8deee | #492f64 | 10.28 |
red | Red / Красный | --marker-red | #ffe2dd | #6e3630 | 8.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— сыройEditorTiptap, его 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 не выражает цвет текста и маркера, выравнивание Блока, подчёркивание, ширину и подпись картинки, слияние ячеек таблицы и список внутри ячейки: картинка выгружается как
,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 с сеткой (шаблонgridAPG,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, разворотtoolssrc/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пакета.