API расширения Редактора
Кратко для AI
Карта того, чем продукт расширяет RichTextEditor из @olddevs/ui/editor, и граница стабильности этого API. Единица расширения — свой Инструмент (CustomTool): описание одной возможности Редактора, которое передаётся в tools вперемешку с именами кита. Он несёт Блоки и Метки с правилами Схемы данными, Часть для движка (расширения Tiptap, сразу или загрузчиком) и UI-записи: кнопки, пункты вставки, Действия над Блоком, Карточки у текста. Код экрана управляет полем через Хэндл (ref) и хук useRichTextEditorState, узнаёт о Готовности и Фокусе поля колбэками onReady, onFocus и onBlur, кладёт своё в Строку статуса (status) и сообщает об ошибке командой showNotice. Тот же массив tools понимают серверная очистка (sanitizeRichText, richTextSchema) и RichTextView с Островами. Всё перечисленное подчиняется semver кита с первого выпуска, HTML-соглашение заморожено, мажор Tiptap — мажор кита (раздел «Стабильность»). Подробности каждого пункта живут на странице RichTextEditor; здесь — карта, граница контракта и рецепты.
import {
RICH_TEXT_PRESETS,
RichTextEditor,
useRichTextEditorState,
type CustomTool,
type RichTextEditorHandle,
} from "@olddevs/ui/editor";
import { sanitizeRichText } from "@olddevs/ui/editor/schema";Решение — ADR-0058, дополняет ADR-0050. Термины с большой буквы — из словаря Редактора.
Когда использовать
- Документу нужен свой Блок, атом или Метка: Merge Tag, Embed, карточка товара, комментарий к фрагменту. Их Схема, HTML, Markdown и показ в
RichTextViewописываются одним Инструментом. - Своя команда в интерфейсе Редактора: кнопка на Панели, пункт в «/» и «+», действие в меню Ручки Блока, карточка у фрагмента текста.
- Экран управляет полем: вставляет шаблон у каретки, рисует свою панель, сохраняет по уходу фокуса, показывает «Сохранено» и число слов.
- Сервер хранит Документ со своими Блоками и должен чистить его тем же набором правил, что Редактор.
Когда не использовать
- Нужное уже есть среди Инструментов кита («Инструменты и пресеты») — передайте имя в
tools, своё не пишите. - Спрятать встроенную кнопку или пункт — сузьте
tools. Подменить встроенный Инструмент по имени нельзя: выключите его и поставьте свой под своим именем. - Произвольный React в строке Панели или в меню — нет: кит рисует записи сам, React допускается только в содержимом поповера (кнопки, пункта вставки, Действия над Блоком) и в Карточке у текста.
- Вмешаться во вставку и drop пропом — нет: это делает Часть для движка своего Инструмента («Порядок вставки и drop»).
- Чего нет до первого запроса (ADR-0058, раздел 13):
insertImageи HTML выделения в Хэндле,onSelectionChange,onPaste,onDrop,onEmpty,onDestroy, события истории, сигнал об удалённых картинках, свои символы-триггеры вроде@, запертые Блоки шаблона, вид записи «меню», Карточки и Строка статуса вRichTextView. До тех пор у экрана естьref.current.editorи его события.
Карта API
| Что | Где в API | Подробно |
|---|---|---|
| Свой Инструмент | CustomTool в tools; пресеты — RICH_TEXT_PRESETS | «Свой Инструмент» |
| Правила Схемы и HTML-соглашение | blocks (CustomBlock), marks (CustomMark), CustomAttribute, PrefixedName | «Свой Инструмент» |
| Кнопка | buttons (CustomToolButton, CustomToolPopover, CustomToolLabel) | «Кнопка своего Инструмента» |
| Пункт вставки, Действие над Блоком | insertItems (CustomInsertItem), blockActions (CustomBlockAction) | «Пункты вставки и Действия над Блоком» |
| Карточка у текста | callouts (CustomCallout), помощник markRange | «Карточка у текста» |
| Хэндл и своя панель | RichTextEditorHandle, useRichTextEditorState(ref, selector) | «Хэндл и своя панель экрана» |
| События | onReady, onFocus, onBlur; вставка и drop — Частью для движка | «События» |
| Строка статуса и Сообщение поля | проп status; команда движка showNotice, тип FieldNotice | «Строка статуса и Сообщение поля» |
| Загрузчик, Готовность и Скелет | engine: () => import(…) | «Загрузчик Части для движка», «Скелет» |
Текст атома: getText, maxLength, пустота | renderText Части для движка | «Текст атома» |
| Сервер, Markdown, Просмотр | sanitizeRichText(html, DOMPurify, tools), richTextSchema(tools), RichTextView | «Вне Редактора», RichTextView, «Свои Инструменты: третий аргумент» |
| Остров своего Блока | CustomBlock.island, CustomIslandProps, проп tools у RichTextView | RichTextView, «Остров своего Блока» |
Свой Инструмент и HTML-соглашение
Описание одно, внутри две части. Правила Схемы — обычные данные без Tiptap: их читают Редактор, серверная очистка и Просмотр. Часть для движка — расширения Tiptap целиком (Node, Mark, Extension, плагины ProseMirror, React-вид узла): только поведение, а разбор и вывод HTML ставит кит. Обязательны имя, Часть для движка и правило Схемы для каждого её Node и Mark; остальное — по умолчанию: обычный Блок без Признаков, Markdown содержимым, ритм абзаца в Просмотре, без UI-записей. Инструмент может не нести Блока вовсе — например, только фильтр вставки.
Всё своё называется с Префиксом потребителя через подчёркивание: Инструмент acme_mergeTag, атрибут acme_size. В именах кита подчёркивания нет, поэтому своё и встроенное не пересекаются ни в наборе, ни в сохранённом Документе. HTML своего пишет и читает кит:
| Что | HTML |
|---|---|
| Блочный Блок | <div data-block="acme_card">…</div> |
| Строчный атом | <span data-block="acme_mergeTag"></span> |
| Метка | <span data-mark="acme_comment">…</span> |
| Атрибут | data-acme_size="m" — значение по правилу: список, шаблон или адрес |
Набор проверяется один раз, при создании движка (с загрузчиками — когда они отработали): имя без префикса, совпадение имён, Часть для движка мимо правил или запись без обязательного поля — ошибка поля «Редактор не запустился», а не тихая потеря Блоков при первом сохранении. Неизвестная строка в tools по-прежнему отбрасывается молча.
UI-записи
Кит рисует, Инструмент описывает. Поэтому роли, одна Tab-остановка Панели, aria-pressed (только при isActive), подсказка с Kbd, рамка поповера и стек Escape у своих записей те же, что у встроенных. У каждой поверхности одно отведённое место, и свои записи стоят в нём в порядке tools, внутри Инструмента — в порядке массива:
| Запись | Поле | Отведённое место |
|---|---|---|
| Кнопка | buttons | Группа Панели перед «Отменить» и «Повторить»; с selectionMenu: true — ещё и Меню выделения |
| Пункт вставки | insertItems | Конец своего раздела («Добавить» или «Превратить в») в «/», «+», «Добавить ›» и «Формат ›» Ручки |
| Действие над Блоком | blockActions | Секция меню Ручки Блока перед «Удалить» — у Блоков из blocks |
| Карточка у текста | callouts | Рамка под диапазоном текста, у которого стоит каретка; открыта не больше одной |
- Подпись — строка или функция от
{ locale }(локальUiProvider); каталогUiProviderдля чужих Инструментов не расширяется, переопределение — опциями фабрики Инструмента (рецепт ниже). - Значок обязателен у кнопки, пункта и Действия: компонент с контрактом
IconPropsиз@olddevs/ui/iconsили свой. - Поповер — у кнопки, пункта вставки и Действия над Блоком ровно одно из
runиpopover. Содержимое — функция от{ editor, close }(у Действия — ещёposБлока), рамку держит кит: слой, стекло всплывающих поверхностей Редактора, стек Escape, имя — подпись записи, возврат фокуса и выделения в Документ. Карточка у текста лежит на том же стекле, поэтому сплошной фон своему содержимому не ставьте: он закроет подложку, которую системная настройка прозрачности и контраста делает сплошной сама. Встаёт у кнопки (щелчок), у каретки (сочетание кнопки, пункт из «/», «+», «Добавить ›», «Формат ›» и его сочетание) или у Блока (Действие из меню Ручки и его сочетание). - Сочетание (
shortcut) — единственный источник: кит вешает клавишу и пишет её в подсказку,aria-keyshortcutsи справочник сочетаний. Клавиши Части для движка в справочник не попадают. - Выключенный Инструмент уносит свои записи отовсюду.
Хэндл, события, Строка статуса
- Хэндл (
ref) — вставка у каретки (insertHTML,insertText→boolean), чтение (getText,getSelectedText,isEmpty,getHTML,getMarkdown), фокус (focus("start" | "end" | "restore"),blur), история (undo,redo,canUndo,canRedo), а ещёclearиsetMarkdown. Вставка — Правка автора: шаг истории,onChangeпосле задержки, пределmaxLength; за пределом вставка не выполняется. До Готовности методы тихие.editor— сырой Tiptap для команд Меток, Блоков и своих Инструментов; его API следует мажору Tiptap. useRichTextEditorState(ref, selector)— состояние своей панели: перерисовка только при смене среза,nullдо движка и на сервере.onReady()— один раз за монтаж, когда наступила Готовность;onFocus()иonBlur()— о Фокусе поля целиком (Документ, Панель, поповеры кита, поле HTML); передonBlurотложенныйonChangeвыталкивается.status— React-узел экрана в строке под Документом, у начала; счётчик кита — в конце. Кит строку не озвучивает.showNotice({ tone: "status" | "error", text } | null)— команда движка: одно Сообщение поля, новое заменяет прежнее;statusтолько озвучивается,errorвидна строкойrole="alert"до следующей Правки автора. Тем же каналом кит сообщает о картинках.
Загрузчики, Готовность и Скелет
Часть для движка можно отдать загрузчиком — engine: () => import("./engine.js").then((m) => m.engine): модуль с описанием тогда импортирует и сервер, а Tiptap уходит в отдельный чанк. Загрузка стартует с первого рендера в браузере, промис кэшируется на функции загрузчика (поэтому загрузчик — одна функция на модуль). Готовность — движок создан и загрузчики всех включённых Инструментов, своих и тяжёлых встроенных (codeBlock, table), отработали. До неё поле — Скелет: рамка, место Панели и Документ, очищенный Схемой, без ввода и без прыжка высоты; на сервере — рамка без Документа. Сбой загрузчика — ошибка поля с «Повторить», Документ остаётся виден. Подробно — «Загрузчик Части для движка» и «Скелет».
Сервер, Markdown, Просмотр и Остров
- Сервер чистит Документ тем же массивом
tools:sanitizeRichText(html, DOMPurify, tools), правила данными —richTextSchema(tools). Оба есть и в лёгком@olddevs/ui/editor/schemaбез Tiptap и React — вместе сmarkRange: описание своего Инструмента с Карточкой у текста собирается и в модуле сервера. Имена кита белый список не сужают; без третьего аргумента очистка прежняя, и свои Блоки срезаются. - Markdown — правила в Части для движка (
renderMarkdown,markdownTokenizer,parseMarkdown). Без них Блок выгружается содержимым, атом — Текстом атома, и текст не теряется. RichTextViewпоказывает свой Блок очищенным HTML с ритмом абзаца; вид — ваш CSS поdata-block. Остров — клиентский компонент в полеislandБлока: оживает после монтирования и только если такой Блок есть в Документе;RichTextViewдля этого получает тот жеtools.
Стабильность
Граница контракта по ADR-0058, п. 12. По этому разделу ревьюер сверяет тип changeset; записи об API расширения начинаются словами «Редактор, расширение:».
Экспериментальной метки нет, semver — с первого выпуска. В контракт входят:
- описание своего Инструмента (
CustomToolи его поля), UI-записи и их отведённые места, правила Схемы (CustomBlock,CustomMark,CustomAttribute), Признаки Блока; - HTML-соглашение (
data-block,data-mark,data-<префикс>_<имя>); - методы Хэндла,
useRichTextEditorState, колбэкиonReady,onFocus,onBlur, пропstatus, командаshowNoticeи типFieldNotice; - третий аргумент
sanitizeRichTextиrichTextSchema, пропtoolsи Остров уRichTextView,markRange; - имена встроенных Инструментов (
ToolName) и сам факт пресетов-массивовRICH_TEXT_PRESETS— но не их состав (ниже).
Не входят: DOM и классы кита вокруг отведённого места; id встроенных кнопок и пунктов; внутреннее описание встроенных Инструментов (надформа) — наружу оно не экспортируется; точное место слота внутри поверхности; вид Скелета; имена файлов и чанков в dist, как любой путь внутри пакета («Публичный контракт»). API сырого editor и Части для движка — это API Tiptap, он следует мажору движка.
Сохранённый Документ обновление пакета не ломает ни в одной версии. HTML-соглашение заморожено: data-block и data-mark расширяются только добавлением. Если форму придётся сменить, это мажор, и до следующего мажора кит читает обе формы — и разбором движка, и санитайзером.
Ломающим считается нарушение контракта, а не сдвиг пикселей:
| Изменение | Версия |
|---|---|
| Новое обязательное поле описания | major |
| Новый Признак Блока или значение по умолчанию, меняющее поведение уже описанных Блоков | major |
Отказ от «одно отведённое место на поверхность» или «свои записи в порядке tools» | major |
| Перенос вида записи на другую поверхность | major |
| Смена формы HTML-соглашения | major, и до следующего мажора читаются обе |
| Новый мажор Tiptap — даже без правок в коде кита: ломаются Части для движка у потребителя | major |
| Удаление Инструмента из пресета, переименование Инструмента | major |
| Удаление публичного имени | major, после @deprecated в minor (ниже) |
| Новое необязательное поле, новый вид записи, метод Хэндла или колбэк | minor |
| Новый встроенный Инструмент в пресете | minor, строка changeset выделена жирным |
| Сдвиг отведённого места внутри поверхности | minor со строкой changeset |
| Подъём нижней границы Tiptap внутри мажора | minor |
| Ужесточение очистки ради безопасности | patch или minor с заметкой в migration-notes |
| Вид Скелета, DOM и классы вокруг отведённого места | вне контракта |
- Мажор Tiptap — мажор кита. Кит поддерживает один мажор Tiptap за раз; двойного диапазона peer-зависимостей нет.
- Пресет — «то, что кит считает полным набором», а не замороженный список. Кому нужен стабильный состав, передаёт точный список имён.
- Ужесточение очистки выходит заметкой в migration-notes: что теперь срезается и как найти такие Документы. Ослабление очистки — отдельное решение, а не вопрос версии.
- Сначала пометка, потом удаление. Замена выходит в minor, старое получает
@deprecatedи одно предупреждение в разработке, удаляется следующим мажором.
Рецепты
Внешний тулбар: preventDefault на mousedown
Кнопки своей панели экрана делают event.preventDefault() на mousedown: фокус остаётся в Документе, и плавающая Панель, меню выделения и панель картинки не прячутся. Состояние кнопок — из useRichTextEditorState, команды — ref.current?.editor?.chain().focus()…run(). Пример и подробности — RichTextEditor, «Своя панель». Правило «клик в свою панель не снимает выделение» — рецепт, а не свойство поля.
«Сохранено» в Строке статуса
Автосохранение по уходу фокуса и озвучка результата. Живой регион стоит в Строке статуса постоянно, меняется только его текст: регион, вставленный вместе с текстом, скринридеры часто не читают. Число слов рядом — без role, чтобы не звучать на каждую правку.
import { useRef, useState } from "react";
import { RichTextEditor, type RichTextEditorHandle } from "@olddevs/ui/editor";
function ArticleBody({
initial,
save,
}: {
initial: string;
save: (html: string) => Promise<void>;
}) {
const ref = useRef<RichTextEditorHandle>(null);
const [html, setHtml] = useState(initial);
const [saved, setSaved] = useState(false);
return (
<RichTextEditor
ref={ref}
aria-label="Текст статьи"
value={html}
onChange={(next) => {
setHtml(next);
setSaved(false);
}}
// Перед onBlur отложенный onChange уже вытолкнут, но состояние ещё не
// перерисовано: свежий Документ — из Хэндла.
onBlur={async () => {
await save(ref.current?.getHTML() ?? html);
setSaved(true);
}}
status={
<span className="flex gap-3">
<span role="status">{saved ? "Сохранено" : ""}</span>
<span>{countWords(html)} слов</span>
</span>
}
/>
);
}Ошибку сохранения показывайте там же, где экран показывает ошибки формы: showNotice — для событий самого поля (отказ вставки, загрузка файла), и строка error уходит с первой правкой автора. Подробно о строке — «Строка статуса: status».
Исчезнувшие картинки: сравнение src на сервере
Сигнала «картинка удалена из Документа» у Редактора нет и не будет до запроса: отмена, вырезание со вставкой и правка соавтора дали бы ложные срабатывания, а Документ меняют и в обход Редактора — запросом, миграцией, другим клиентом. Поэтому загруженные файлы, на которые Документ больше не ссылается, находит сервер: при сохранении сравнивает адреса картинок очищенного Документа с прошлой версией.
import { JSDOM } from "jsdom";
import createDOMPurify from "dompurify";
import { sanitizeRichText } from "@olddevs/ui/editor/schema";
import { EDITOR_TOOLS } from "./editor-tools"; // тот же массив, что в `tools` Редактора
const { window } = new JSDOM("");
const DOMPurify = createDOMPurify(window);
/** Адреса картинок Документа. Разбор — в инертном `template`: скрипты не исполняются. */
function imageSources(html: string): Set<string> {
const template = window.document.createElement("template");
template.innerHTML = html;
return new Set(
[...template.content.querySelectorAll("figure > img[src]")].map((img) =>
img.getAttribute("src")!
)
);
}
export async function saveDocument(id: string, dirty: string) {
const html = sanitizeRichText(dirty, DOMPurify, EDITOR_TOOLS);
const before = imageSources((await documents.get(id))?.html ?? "");
const after = imageSources(html);
await documents.put(id, html);
// Не удалять сразу: тот же файл может стоять в другом Документе, черновике или
// версии, которую автор вернёт отменой. Пометить — и решить отложенной задачей.
await uploads.markUnreferenced(
[...before].filter((src) => !after.has(src)),
{ documentId: id }
);
}Сравнивайте очищенный HTML: только в нём картинка гарантированно figure > img с абсолютным http/https. Адрес своего Блока (атрибут вида url) ищется так же — по [data-block="acme_video"] и его data-acme_src.
Подписи через опции фабрики
Каталог UiProvider закрыт для чужих Инструментов, поэтому переводы и переопределения подписей своего Инструмента — опции его фабрики. Подпись-функция получает { locale } и перерисовывается со сменой локали; строка подходит одноязычному приложению.
import { RICH_TEXT_PRESETS, type CustomTool, type CustomToolLabel } from "@olddevs/ui/editor";
import { MentionIcon } from "@olddevs/ui/icons";
interface MentionLabels {
insert: CustomToolLabel;
}
const DEFAULT_LABELS: MentionLabels = {
insert: ({ locale }) => (locale.startsWith("ru") ? "Упоминание" : "Mention"),
};
// Загрузчик — одна функция на модуль: промис кэшируется на ней.
const loadEngine = () => import("./mention-engine.js").then((module) => module.engine);
export function mentionTool(options: { labels?: Partial<MentionLabels> } = {}): CustomTool {
const labels = { ...DEFAULT_LABELS, ...options.labels };
return {
name: "acme_mention",
engine: loadEngine,
blocks: {
acme_mention: {
kind: "inline",
attributes: { acme_id: { kind: "pattern", pattern: "[0-9]+" } },
},
},
insertItems: [
{
section: "insert",
label: labels.insert,
icon: MentionIcon,
aliases: ["mention", "user"],
run: (chain) => chain.insertContent({ type: "acme_mention", attrs: { acme_id: null } }),
},
],
};
}
// Экран: набор собирается один раз на модуль, а не в рендере, —
// `tools` читается при создании движка.
export const EDITOR_TOOLS = [
...RICH_TEXT_PRESETS.full,
mentionTool({ labels: { insert: "Отметить коллегу" } }),
];Пустая подпись не роняет поле: в разработке — console.error с именем Инструмента и записи, а в имя и подсказку встаёт имя Инструмента.
modulepreload для ленивых чанков
Предзагрузки кит не публикует: чанки Частей для движка — своих загрузчиков и встроенных codeBlock и table в full — начинают грузиться с первого рендера Редактора, и это время закрывает Скелет. Чтобы чанк пришёл вместе со страницей, поставьте на него <link rel="modulepreload"> (в React 19 — preloadModule(url) из react-dom); адрес с хешем — из манифеста своего сборщика, имена чанков кита контрактом не являются. Пример — RichTextEditor, «Загрузчик Части для движка»; цена «старта» и «полного» — «Сколько стоит импорт».
Ссылки
- Страницы: RichTextEditor, RichTextView, словарь Редактора, migration-notes.
- Решения: ADR-0058 — API расширения, ADR-0050 — Редактор.
- Исходник: вход
src/editor/index.ts, публичная форма своего Инструментаsrc/editor/customTool.ts, проверка набора и проводкаsrc/editor/customToolEngine.ts, Хэндлsrc/editor/handle.ts, хукsrc/editor/useRichTextEditorState.ts, Сообщение поляsrc/editor/fieldNotice.ts, Схема и очисткаsrc/editor/schema.ts, Островаsrc/editor/viewIsland.ts. - Тесты:
tests/rich-text-editor-custom-tool.test.tsx(описание, Схема, проверка набора),tests/rich-text-editor-custom-button.test.tsx,tests/rich-text-editor-custom-ui.test.tsxиtests/rich-text-editor-callout.test.tsx(UI-записи),tests/rich-text-editor-handle.test.tsxиtests/rich-text-editor-handle-ssr.test.tsx(Хэндл и хук),tests/rich-text-editor-events.test.tsx(колбэки, порядок вставки и drop),tests/rich-text-editor-notice.test.tsx(Строка статуса и Сообщение поля),tests/rich-text-editor-loaders.test.tsx,tests/rich-text-editor-skeleton.test.tsxиtests/rich-text-editor-lazy-builtins.test.tsx(загрузчики, Скелет, ленивые встроенные),tests/rich-text-editor-atom-text.test.tsx(Текст атома),tests/rich-text-editor-custom-markdown.test.tsx,tests/editor-sanitize-tools.test.tsx,tests/rich-text-view-island.test.tsx,tests/editor-entry.test.ts; тестовые Инструменты —tests/editor-test-tools.ts.