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

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 у RichTextViewRichTextView, «Остров своего Блока»

Свой Инструмент и 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.

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