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

RichTextView

Кратко для AI

RichTextView — показ сохранённого HTML RichTextEditor с той же типографикой, что внутри редактора. Движка нет: в графе модулей ни Tiptap, ни lowlight — компонент рендерится в серверном компоненте и сам не несёт "use client"; клиентских кусков в графе два, оба острова: кнопка «Копировать» у блоков кода и — только при пропе tools и своём Блоке в Документе — Остров своего Блока (раздел «Остров своего Блока»). Он не санитизирует HTML: html должен прийти от сервера уже очищенным по белому списку Схемы — sanitizeRichText(html, DOMPurify) из @olddevs/ui/editor/schema (раздел «Санитизация»). Живёт во входе @olddevs/ui/editor, не в barrel. Подсветку блоков кода не делает сам: HTML подсвечивает на сервере highlightCodeBlocks(html) из @olddevs/ui/editor/highlight после очистки, а RichTextView красит готовые токены темой GitHub Light/Dark. У каждого блока кода — шапка с языком слева и кнопкой «Копировать» справа (раздел «Кнопка «Копировать» и шапка»).

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

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

  • Страница показывает документ, который сохранил RichTextEditor: статью, описание, регламент.
  • Компонент нужен в серверном компоненте или там, где тащить Tiptap ради чтения нельзя.

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

  • HTML пришёл от пользователя и сервер его не очищал — сначала очистка (раздел «Санитизация»), потом показ. Компонент положит в страницу всё, что получит.
  • Нужна правка — RichTextEditor. Его readOnly держит движок живым ради текста, который не меняется; для страницы просмотра берите RichTextView.
  • Произвольный HTML вне Схемы (style вне цвета автора, thead, caption, картинки без figure): типографика рассчитана на теги Схемы, остальное будет выглядеть как у браузера по умолчанию.
  • Короткий однострочный текст — Text.

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

В Next.js вход @olddevs/ui/editor должен стоять в optimizePackageImports своей строкой (getting-started.md): иначе вместе с просмотром в браузер уедет весь Редактор с движком — 435 кБ First Load JS вместо 137.

// Серверный компонент: директива не нужна.
import { RichTextView } from "@olddevs/ui/editor";

export default async function ArticlePage({ id }: { id: string }) {
  const article = await loadArticle(id); // article.html очищен при сохранении
  return <RichTextView html={article.html} className="max-w-prose" />;
}

Public API

  • html — сохранённый Документ, HTML-строка. Не санитизируется: кладётся в разметку через dangerouslySetInnerHTML, как есть. Пустая строка даёт пустой контейнер.
  • className — добавляется после классов типографики и может их переопределять.
  • surface — "theme" (по умолчанию) или "paper": светлый лист в любой теме, см. «Поверхность». Тип — RichTextSurface.
  • tools — свои Инструменты Документа, тот же массив, что у RichTextEditor и sanitizeRichText. Просмотр читает из него только Острова своих Блоков (blocks[имя].island, тип CustomIslandProps); без tools Просмотр прежний (раздел «Остров своего Блока»).
  • Остальное — обычные пропсы div: id, aria-*, data-*, ref. children и dangerouslySetInnerHTML не принимаются.
  • highlightCodeBlocks(html) — серверная подсветка блоков кода, вход @olddevs/ui/editor/highlight (раздел «Подсветка блоков кода»). Проп для этого у RichTextView нет намеренно.
  • sanitizeRichText(html, purify, tools?) — очистка HTML по Схеме экземпляром DOMPurify, который передаёт потребитель; ставит хуки Схемы на время вызова. Тип экземпляра — структурный RichTextPurifier (событие хука — RichTextAttributeHookEvent). Третий аргумент — тот же массив tools, что у Редактора: с ним проходят свои Блоки и Метки по их правилам (раздел «Свои Инструменты: третий аргумент»); без него очистка прежняя. Безопасный путь по умолчанию (раздел «Санитизация»).
  • richTextSchema(tools?) — Схема данными для набора tools: поля RICH_TEXT_SCHEMA плюс customBlocks и customMarks — правила своих Блоков и Меток (RichTextCustomBlockRules, RichTextCustomMarkRules). Тип — RichTextSchema. Сама константа RICH_TEXT_SCHEMA — по-прежнему Схема кита.
  • richTextSanitizeConfig() — закрытый конфиг очистки для DOMPurify без хуков: безопасен сам по себе, но часть разметки Редактора теряет. Тип — RichTextSanitizeConfig.
  • Все три — только данные и функции, без зависимостей; в серверном модуле безопасны. Лёгкий вход — @olddevs/ui/editor/schema, из @olddevs/ui/editor доступны тоже.

Типографика — тот же модуль src/editor/prose.ts, что вешает классы на содержимое Редактора: текст в Редакторе и на странице выглядит одинаково. Классы лежат на контейнере и находят теги по имени, поэтому в HTML Документа классов и style нет. Цвета берутся из токенов кита, и темы dark, light, dim переключаются сами. Отдельный @source для Tailwind не нужен: строка из getting-started.md покрывает классы.

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

surface="paper" — светлый лист в любой теме приложения, как у RichTextEditor: текст, ссылки, палитры, маркеры, таблицы, подписи и блоки кода (GitHub Light) выглядят как в светлой теме, а лист отделён от страницы рамкой и тенью темы приложения. По умолчанию ("theme") Документ следует теме приложения и фона не красит.

<RichTextView html={cleanHtml} surface="paper" />

Когда брать лист: предпросмотр печати и PDF, просмотр письма или документа, который уйдёт из приложения светлым. Документ, который читают только в приложении, остаётся на "theme". Всё вокруг листа — заголовки страницы, кнопки, меню — в теме приложения: лист покрывает только контейнер RichTextView.

Компонент остаётся серверным: лист — это классы и атрибуты контейнера (data-theme="light", data-surface="paper"), без style и без клиентского кода. Один документ на листе выглядит одинаково в Редакторе и здесь — это сверяет тест.

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

Схема — единственный источник допустимой разметки Редактора: всё, чего в ней нет, он отбрасывает и при загрузке, и при вставке. RichTextView Схему не проверяет: показ идёт без разбора HTML.

Белый список тегов и атрибутов (таблица повторяет src/editor/schema.ts и проверяется тестом; список растёт вместе с набором Блоков и Меток):

ЧтоРазрешено
Блоки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>. sanitizeRichText пускает input, label, span и div только внутри такого пункта, а input — только с type="checkbox".

Таблицу Редактор пишет так: <table><tbody><tr><th><p>…</p></th></tr><tr><td colspan="2" data-align="center"><p>…</p></td></tr></tbody></table> — строка-заголовок — первая строка из th, а у ячейки бывают только три атрибута: colspan и rowspan (слияние, целое от 2 до RICH_TEXT_SCHEMA.tableSpans — 20 по горизонтали и 50 по вертикали; 1 не пишется) и data-align (выравнивание столбца, center или right, на каждой ячейке столбца). Строка, целиком накрытая слиянием сверху, остаётся пустой <tr></tr>. thead, tfoot, colgroup, caption в Схеме нет, и DOMPurify по умолчанию вместе с thead и colgroup убирает и их содержимое: заголовок чужой таблицы, записанный в thead, пропадёт. Редактор thead не пишет, так что для его HTML это не потеря. tbody, tr, th и td вне таблицы парсер HTML выбрасывает до очистки (текст остаётся), style и colwidth не проходят, а colspan и rowspan пропускает только хук sanitizeRichText — на th и td и только цифрами в пределах Схемы; голый конфиг их не знает вовсе. Без такой проверки colspan="10000" из чужого HTML развернётся в десять тысяч пустых ячеек у каждого, кто откроет документ.

Других классов, style (кроме произвольного цвета, см. ниже), aria-* и data-* (кроме пяти имён Схемы: data-type и data-checked чек-листа, data-color цвета, data-align выравнивания и data-width ширины картинки) в отдаваемом HTML нет. h1 в Документе бывает только у Редактора с инструментом heading1 (по умолчанию в full); экран, у которого H1 стоит над Редактором, этот инструмент выключает.

Картинка Редактор пишет так: <figure><img src="https://…" alt="…" data-width="50"><figcaption>…</figcaption></figure>. figcaption есть, только когда подпись не пуста, data-width — только у картинки с заданной шириной (50, 75 или 100 процентов ширины Документа, RICH_TEXT_SCHEMA.imageWidths; без него — свой размер, не шире Документа), alt есть всегда и пуст у картинки без описания. Адрес — только абсолютный http или https (RICH_TEXT_SCHEMA.imageProtocols): data: (base64) и blob: Редактор не пишет никогда, а style, width, height и class у картинки не бывает — ни у img, ни у figure. Голый <img> вне figure Редактор не отдаёт; sanitizeRichText его удаляет.

Цвет и выравнивание — имена, а не значения CSS. data-color принимает только имена палитры Редактора, и палитр две — по тегу. На span (цвет текста) — accent, success, warning, danger, lime, muted (RICH_TEXT_SCHEMA.textColors), на mark (маркер) — gray, brown, orange, yellow, green, blue, purple, pink, red (RICH_TEXT_SCHEMA.markerColors): яркие yellow, green, pink предлагает меню Редактора, остальные шесть остаются читаемыми и красятся прежними значениями; имя чужой палитры на теге не проходит. Документы, сохранённые до палитры маркера, несут на mark шесть старых имён — они переводятся (RICH_TEXT_SCHEMA.legacyMarkerColors, таблица — в документации Редактора): sanitizeRichText делает это хуком, а сам RichTextView красит старое имя тем же цветом, в который оно переводится. data-align — center или right (left Редактор не пишет, а читает как отсутствие атрибута; RICH_TEXT_SCHEMA.alignments). Конкретные цвета подставляет тема, поэтому один и тот же сохранённый текст читается в dark, light и dim: RichTextView и Редактор красят data-color теми же классами (prose.ts). DOMPurify значений атрибутов не проверяет — это делают хуки sanitizeRichText.

Порядок Меток у маркера. С OLDSUI-60 Редактор пишет маркер самой внешней Меткой после цвета текста — <span data-color><mark><sup>… — и полоса не прыгает на индексах и не рвётся на ссылке. RichTextView HTML не перестраивает: документ, сохранённый раньше как <sup><mark>…</mark></sup>, показывается в старом порядке, и кусок маркера на индексе поднимается вместе с ним. Порядок выправляется, когда документ один раз открывают и сохраняют в Редакторе.

Произвольный цвет — единственное исключение из «style нет». Кроме цветов палитры автор может выбрать любой в ColorPicker («Свой цвет…» в меню цвета Редактора). У такого цвета нет имени, а носитель значения в HTML — style, поэтому в документ он пишется строго так: <span style="color:#ff0000"> для текста и <mark style="background-color:#ffee00"> для маркера. Правило узкое и записано в Схеме (RICH_TEXT_SCHEMA.customColor.styles, регулярные выражения строкой): строчные буквы, ровно шесть цифр, без пробелов и ;, одно объявление на тег, и никогда вместе с data-color на одном элементе. Любой другой style — #f00, rgb(…), red, второе объявление рядом, style на другом теге — Редактор отбрасывает при загрузке и вставке, и sanitizeRichText снимает его так же: хук сверяет тег и значение с теми же выражениями. В голом richTextSanitizeConfig() style нет вовсе — ALLOWED_ATTR у DOMPurify общий на все теги, и без хука он пропустил бы любой CSS.

Цвет автора не следует за темой. Цвета палитры — токены, и один сохранённый текст читается в dark, light и dim. #ff0000 — значение: в каждой теме он одинаков, и пара «цвет — фон» может не держать контраст там, где автор её не видел. Редактор предупреждает об этом при выборе (контраст 4.5:1 против фона текущей темы, для маркера — основной текст на нём), но не запрещает и не пересчитывает цвет при смене темы. Если документ показывается в нескольких темах, пользуйтесь палитрой.

Ссылка Редактор пишет так: <a href="https://…" rel="noopener noreferrer">…</a>. Адрес — только абсолютный, со схемой http, https или mailto: относительные адреса и прочие схемы (javascript:, data:, ftp:) Редактор не принимает ни из value, ни из вставки. target он не пишет: открывать ли ссылки в новой вкладке, решает страница показа, а не Документ.

Санитизация — на сервере потребителя

Клиентская очистка от XSS не защищает: запрос в обход интерфейса принесёт что угодно. HTML очищается на сервере при сохранении (а лучше и при выдаче, если в базе лежат старые записи). DOMPurify в зависимости кита не входит — ставите сами, а политику Схемы приносит кит хелпером sanitizeRichText:

npm install dompurify jsdom   # или isomorphic-dompurify
import { JSDOM } from "jsdom";
import createDOMPurify from "dompurify";
import { sanitizeRichText } from "@olddevs/ui/editor/schema";

const DOMPurify = createDOMPurify(new JSDOM("").window);

export function sanitizeDocument(html: string): string {
  return sanitizeRichText(html, DOMPurify);
}

sanitizeRichText(html, purify) берёт экземпляр DOMPurify аргументом — кит его не импортирует, тип экземпляра структурный (RichTextPurifier), и dompurify с jsdom или isomorphic-dompurify подходят без приведений. На время одного вызова хелпер ставит на экземпляр хуки Схемы и снимает их после: экземпляр у вас общий (комментарии, письма), и правила Редактора не срабатывают в чужой очистке, а ваши хуки остаются на месте. Разметка Редактора проходит байт в байт; всё остальное хуки снимают — текст из снятой обёртки остаётся, как и в Редакторе. Что они проверяют:

  • label, input, span и div — только разметка пункта чек-листа; input — только type="checkbox"; ul[data-type], li[data-type], li[data-checked] — только значения чек-листа;
  • ol[start] — только на ol;
  • code[class] — только канонический language-<имя> из RICH_TEXT_SCHEMA.codeLanguages: псевдоним (language-py), неизвестный язык, второй класс и класс на другом теге снимаются, токены hljs-* — тоже;
  • span[data-color] и mark[data-color] — только имена своей палитры; старые имена маркера переводятся (legacyMarkerColors); mark без цвета снимается;
  • style — только color:#rrggbb на span и background-color:#rrggbb на mark (RICH_TEXT_SCHEMA.customColor.styles) и никогда вместе с data-color;
  • data-align — center или right на p, h1–h4, th, td;
  • colspan и rowspan — только на th и td, целое не больше RICH_TEXT_SCHEMA.tableSpans;
  • ссылка — href только http, https или mailto, rel всегда noopener noreferrer, чужой target снимается, ссылка без годного адреса становится текстом;
  • картинка — img с абсолютным http/https прямо внутри figure (без data: и blob:), data-width — пресет, alt есть всегда; img вне figure удаляется, figcaption вне figure теряет обёртку.

Проверено настоящим DOMPurify 3.4.16 на jsdom в tests/editor-sanitize.test.tsx: Документ со всеми Блоками и Метками Редактора проходит без изменений, а подделки — классы Tailwind (class="fixed inset-0"), style на p, картинка data:, ссылка javascript:, имя чужой палитры, label вне пункта, colspan="10000" и другие — снимаются.

Свои Инструменты: третий аргумент

Если Редактор работает со своими Инструментами (RichTextEditor, «Свой Инструмент»), сервер передаёт очистке тот же массив tools. Описание своего Инструмента — данные, и модуль с ним импортируется на сервере: очистка читает только правила Схемы (blocks, marks), Часть для движка не трогает. Чтобы в графе сервера не оказался и Tiptap, держите описание в модуле без импорта расширений — Часть для движка тогда подаётся загрузчиком (RichTextEditor, «Загрузчик Части для движка») или собирается рядом с Редактором.

import { sanitizeRichText } from "@olddevs/ui/editor/schema";
import { EDITOR_TOOLS } from "./editor-tools"; // тот же массив, что в `tools` Редактора

export function sanitizeDocument(html: string): string {
  return sanitizeRichText(html, DOMPurify, EDITOR_TOOLS);
}
  • Свой Блок проходит как div[data-block="<имя>"], строчный атом — как span[data-block="<имя>"], своя Метка — как span[data-mark="<имя>"]. Блок другого вида (атом в div, блочный Блок в span) и неизвестное имя теряют обёртку, текст остаётся.
  • Свои атрибуты data-<имя> — только на своём элементе и только со значением по правилу: из списка, по шаблону или адрес по политике ссылки (абсолютный http, https, mailto). Значение с разметкой (<) не проходит никогда. Недопустимое значение снимается вместе с атрибутом, сам элемент остаётся — так же поступает Редактор.
  • Чужое на своём элементе — class, style, data-color, обработчики, атрибут другого Блока — снимается.
  • Место top — только верхний уровень Документа: вложенный Блок (в цитату, Обёртку, другой Блок) теряет обёртку, как Раскладка.
  • Имена Инструментов кита белый список не сужают. ["bold", acmeCard] очищает так же, как [...RICH_TEXT_PRESETS.full, acmeCard]: экраны с разными наборами пишут один Документ, и сервер не вправе срезать таблицу, которую записал экран с full. Правила с именем без Префикса потребителя сервер не читает: такой набор Редактор отвергает ошибкой поля, а на сервере он не должен открыть обход правил кита.
  • Без третьего аргумента очистка та же, что была до своих Инструментов: свои Блоки снимаются, как любой чужой HTML.

richTextSchema(tools) отдаёт те же правила данными — для своего санитайзера или проверки на стороне базы: customBlocks (вид, место, атрибуты — с заполненными умолчаниями) и customMarks. Признаков Блока (container, turnInto, plainAtom) там нет: это поведение Редактора, а не то, что приходит в HTML. Проверено в tests/editor-sanitize-tools.test.tsx: Документ Редактора со своими Блоками, атомом и Меткой проходит очистку байт в байт, а подделки снимаются так же, как их снимает Редактор.

richTextSanitizeConfig() — закрытый по умолчанию. Это конфиг для того, кто передаёт его DOMPurify напрямую, без хуков. В общий ALLOWED_ATTR у DOMPurify (один список на все теги, значений он не проверяет) попадает только безвредное на любом теге и с любым значением: data-type, data-checked, data-color, data-align, data-width, start, alt, rel и href. style, class, src, colspan, rowspan, type, checked и теги input и img разрешает только sanitizeRichText, сверив тег и значение: без проверки они пропустили бы любой CSS и класс, base64-картинку и десять тысяч пустых ячеек. Голый конфиг поэтому безопасен, но теряет часть разметки Редактора — цвет автора, язык блока кода, слияние ячеек, флажки и картинки. ALLOW_DATA_ATTR и ALLOW_ARIA_ATTR выключены: по умолчанию DOMPurify пропускает любые data-* и aria-*. Каждый вызов отдаёт новые массивы.

Адрес ссылки конфиг проверяет сам: ALLOWED_URI_REGEXP пускает только http:, https: и mailto: — ту же политику, что Редактор. DOMPurify сверяет с этим выражением каждый разрешённый атрибут, кроме «безопасных для URI», и строгое выражение срезало бы start="3" или data-color="danger"; поэтому все атрибуты, кроме адресов, конфиг объявляет безопасными (ADD_URI_SAFE_ATTR). Адрес картинки выражением не закрыть: data: в src у <img> DOMPurify пропускает в обход ALLOWED_URI_REGEXP — поэтому src разрешает только хук.

Все берутся из отдельного лёгкого входа @olddevs/ui/editor/schema: в нём только Схема данными (RICH_TEXT_SCHEMA, richTextSchema), sanitizeRichText, richTextSanitizeConfig() и их типы, а ещё помощник Карточки у текста markRange для описания своего Инструмента — без React и без Tiptap, поэтому серверу, у которого редактора нет, peer-зависимости ставить не нужно. Те же имена по-прежнему отдаёт @olddevs/ui/editor, но этот вход на сервере без сборщика загрузит весь редактор вместе с движком.

Подсветка блоков кода

В Документе у блока кода только язык — <pre><code class="language-python">…</code></pre>; токенов подсветки в нём нет (ADR-0051). Подсвечивает сохранённый HTML серверный хелпер:

// Серверный компонент.
import { RichTextView } from "@olddevs/ui/editor";
import { highlightCodeBlocks } from "@olddevs/ui/editor/highlight";

export default async function ArticlePage({ id }: { id: string }) {
  const article = await loadArticle(id); // article.html очищен при сохранении
  return <RichTextView html={highlightCodeBlocks(article.html)} />;
}
  • Что делает. В каждом <pre><code class="language-…"> на языке Редактора (RICH_TEXT_SCHEMA.codeLanguages, псевдонимы тоже) текст заменяется токенами <span class="hljs-…">, разобранными lowlight той же грамматикой, что в Редакторе, а класс блока пишется каноническим именем Схемы: language-py и language-PY выходят language-python, language-js — language-javascript. Остальной HTML не меняется ни на байт. Блок без языка, на неизвестном языке и блок, внутри которого уже есть разметка, остаются как были — поэтому повторный вызов ничего не меняет.
  • Экранирование. Хелпер не разбирает HTML, а ищет блоки выражением: внутри блока Схема допускает только текст. Текст раскрывается из сущностей (&lt;, &amp;, &quot;, &#39;, &nbsp;, числовые) и после подсветки снова экранируется: <script> в коде остаётся текстом &lt;script&gt;. Блок с другой именованной сущностью (&copy;) не подсвечивается — лучше без цвета, чем с другим текстом. На выходе только span с class="hljs-…", без style.
  • Порядок — очистка, потом подсветка. Хелпер рассчитан на разметку Схемы, которую отдаёт санитайзер, а токенов hljs-* в Схеме нет — sanitizeRichText их снимает. Подсвечивайте после очистки: при выдаче страницы или сразу после очистки при сохранении. Если в базе лежит подсвеченный HTML, повторная очистка при выдаче вернёт блок к голому тексту, а highlightCodeBlocks подсветит его заново; Редактор, получив такой HTML в value, токены отбросит сам.
  • Тема. Цвета токенов — GitHub Light в теме light, GitHub Dark в dark и dim, как в Редакторе: правила .hljs-* — классы на контейнере RichTextView (src/editor/codeTheme.ts), цвета — переменные в styles.css на элементах с data-theme. Блок кода берёт тему ближайшего предка с data-theme, поэтому RichTextView во вложенном data-theme="light" внутри тёмного приложения показывает GitHub Light. Ни style, ни отдельного CSS.
  • Цена. Вход @olddevs/ui/editor/highlight — хелпер, lowlight, ядро highlight.js и восемь грамматик, 22,6 кБ gzip; Tiptap в его графе нет, и Node без сборщика его загружает. Из peer-зависимостей кита в графе только react — ради второго хелпера входа, highlightCodeNodes для CodeView (ADR-0052). RichTextView его не импортирует и остаётся около 8,4 кБ (с островом «Копировать») — поэтому подсветка не проп компонента: флаг не убрал бы подсветчик из графа, а платил бы за него каждый показ. Ставить для входа нужно только lowlight и highlight.js (опциональные peer-зависимости).

Кнопка «Копировать» и шапка

У каждого блока pre > code над кодом стоит шапка: слева название языка, справа кнопка «Копировать» — CopyButton кита с подсказкой «Copy code» → «Copied». Кнопка есть всегда, пропа для отключения нет (решение владельца): шапка — часть вида блока, и в Редакторе она такая же (RichTextEditor). Обе части видны без наведения, на десктопе и на касании; код начинается под полосой, и кнопка его не перекрывает.

Как это устроено, и почему компонент остаётся серверным. RichTextView не получает обработчиков и состояния — он по-прежнему строка в разметку:

  1. Рядом с dangerouslySetInnerHTML он оборачивает каждый блок <pre><code> в шапку строка в строку (withCodeHeaders, src/editor/codeHeader.ts): подпись языка берётся из списка языков Редактора по приведённому имени (класс из документа в разметку не попадает: language-py даёт «Python», language-rust — пустую подпись) и пустое гнездо для кнопки. Это разметка компонента, собранная после очистки, поэтому санитайзеру ничего разрешать не нужно.
  2. Если в Документе есть хоть один блок кода, рядом с контейнером он рисует остров CodeCopyIsland (src/editor/CodeCopyIsland.tsx, свой "use client"); у Документа без кода острова нет вовсе — ни разметки, ни эффекта. После монтирования остров находит гнёзда своего просмотра и порталом вставляет в каждое CodeCopyButton. На сервере и при гидрации остров рисует только пустой инертный <template> — метку своего места, порталов ещё нет, — поэтому разметка сервера и первый клиентский проход совпадают, а страница без JS читается так же: подпись языка и код на месте, кнопки нет.
  3. Связь острова с гнёздами — его место в дереве. Контейнер ссылкой не передать — у серверного компонента её нет, — поэтому остров берёт соседа своей метки <template> слева, то есть контейнер своего просмотра, и ищет гнёзда только в нём, а не по глобальному document: просмотр в теневом корне или в iframe получает кнопки так же, а обход не идёт по всей странице. Гнездо дополнительно сверяется с идентификатором useId просмотра (единственный хук, и он разрешён серверным компонентам), а короткий отпечаток HTML сообщает острову о смене документа — тогда он ищет гнёзда заново. Два просмотра на странице кнопок не путают.

Граница клиента проходит по файлу острова, а не по RichTextView: tests/rich-text-view.test.tsx требует ровно один такой файл в графе (и то, что он тянет — кнопку и подсказку кита, react-dom, иконки и каталог сообщений), а @tiptap/*, lowlight и highlight.js по-прежнему запрещает; tests/rsc-gate.test.ts сверяет, что в самом RichTextView.tsx директивы нет. Остров в серверном компоненте Next.js работает без обёртки.

Что копируется. textContent элемента code — исходный код строкой. И для блока без подсветки, и для HTML после highlightCodeBlocks это один и тот же текст: токены span текста не добавляют. Это правило ADR-0039: копируется текст, а не DOM — иначе в буфер уехали бы границы токенов подсветки. Отказ буфера кнопка показывает («Could not copy»), а не изображает успех.

Подпись у блока без языка нет. Строка «Обычный текст» — из каталога сообщений, которого у серверного компонента нет, а английская подпись у русского потребителя была бы ошибкой. Шапка у такого блока — одна кнопка справа; в Редакторе в режимах readOnly и disabled то же самое.

Клавиатура. Кнопка в обычном порядке Tab: её тут единственный путь с клавиатуры. Подсказка по фокусу и наведению, Enter и Space копируют.

Цена. Остров тянет кнопку и подсказку кита, react-dom для портала и react-aria-components ради useLocale в каталоге сообщений; @radix-ui/react-tooltip и lucide-react — обязательные peer-зависимости кита, ставить новых не нужно. Замер editorView (RichTextView из @olddevs/ui/editor, движок в замер включён) вырос с 1,7 до 8,4 кБ gzip — это цена острова, а не типографики; после листа surface="paper" и метки острова — 9,1 кБ при бюджете 10,4 кБ (ADR-0030). Замер статический — граф модулей входа, — поэтому то, что Документу без блоков кода остров не рисуется, числа не меняет: файл острова в графе остаётся. Экономия — на клиенте: ни метки в разметке, ни эффекта, ни обхода контейнера. Так же и у Next.js: клиентский граф страницы строится по импортам, а не по тому, что отрисовано.

Таблицы

Редактор пишет table > tbody > tr > th|td, у ячейки — только слияние (colspan, rowspan) и выравнивание столбца (data-align) (раздел «Таблицы» страницы RichTextEditor), а оформление — границы, отступы ячеек, подложка строки-заголовка, выравнивание текста в ячейке по data-align — берёт из prose.ts на контейнере: в Редакторе и в RichTextView таблица выглядит одинаково. Слитая ячейка — обычная ячейка браузерной таблицы с colspan/rowspan, своего стиля у неё нет.

Прокрутка на узком экране — на самой <table>. display: block; max-width: 100%; overflow-x: auto: таблица шире блока прокручивается внутри себя и не раздвигает страницу, минимальная ширина столбца — 6 rem (min-w-24), поэтому таблица из пяти столбцов на экране в 375 px прокручивается, а не сжимается в щели. Обёртки нет намеренно: RichTextView кладёт сохранённый HTML как есть, а Схема не знает ни div-обёртки, ни tabindex.

Цена этого выбора, о которой стоит знать до выпуска:

  • Табличная семантика. На table с display: block часть скринридеров теряет строки и столбцы. В Chrome и Firefox это давно исправлено, в Safari с VoiceOver зависит от версии; проверяйте в ручном прогоне на своих версиях. Альтернатива — table-layout: fixed без прокрутки — оставляет семантику, но делает все столбцы равными и узкими и рвёт длинные слова, на телефоне это хуже.
  • Фокус на прокручиваемой области. Правило axe scrollable-region-focusable требует, чтобы прокручиваемый блок можно было достичь с клавиатуры. В Редакторе в каждой ячейке есть каретка, а в RichTextView фокусируемого содержимого нет: правило срабатывает на таблице, которая реально шире экрана. Закрыть это можно только обёрткой с tabindex="0", а её в сохранённый HTML не положить. Если на вашем экране такие таблицы бывают, оберните RichTextView своим контейнером с overflow-x: auto и tabindex="0" или держите таблицы узкими.

Раскладка

Раскладка Редактора (div[data-block="layout"] с вариантом в data-layout и колонками div[data-block="column"], раздел «Раскладка» страницы RichTextEditor) рисуется той же сеткой, что в Редакторе, — классами prose.ts на контейнере, без style в разметке. Рамок у колонок нет: пунктир — подсказка автору в Редакторе, читателю граница не нужна. Колонки стоят рядом, когда широк сам Документ (две — от 30 rem, три — от 40 rem, четыре — от 48 rem), уже — столбцом в порядке чтения, слева направо. Порог — контейнерный запрос по ширине Документа: корень RichTextView с Раскладкой становится контейнером (container-type: inline-size), поэтому в сжимающем контексте (элемент flex-ряда без ширины) дайте ему ширину. На листе surface="paper" — та же сетка.

sanitizeRichText держит Раскладку там, где её пишет Редактор: только на верхнем уровне, с вариантом из RICH_TEXT_SCHEMA.layouts, колонки — только прямо в ней; иначе обёртки снимаются, Блоки остаются подряд.

Обёртка

Обёртка Редактора (div[data-block="wrapper"] с Блоками внутри, раздел «Обёртка» страницы RichTextEditor) без тона в показе не видна: рамки нет, отступы между её Блоками — те же, что между Блоками Документа, и текст читается так, будто обёртки нет. Пунктир — подсказка автору в Редакторе. Своей роли у Обёртки нет.

Обёртка с тоном (data-tone: accent, success, neutral, warning или danger, OLDSUI-78) видна и читателю — тем же видом, что в Редакторе: мягкая подложка тона и полоса 3 px слева, текст — обычный текст Документа, внутренний отступ, как у Alert. Цвета — роли --rte-tone-* классов prose.ts, без style в разметке; на листе surface="paper" — значения светлой темы. Роли и aria-* тон не добавляет: смысл заметки несёт слово в её тексте («Внимание: …»), а не цвет. Тон вне этих пяти имён RichTextView не красит — sanitizeRichText снимает его на сервере. Палитра, контраст и Markdown — раздел «Обёртка», «Тон».

sanitizeRichText держит Обёртку там, где её пишет Редактор: на верхнем уровне и прямо в колонке Раскладки, не в другой Обёртке, списке, цитате и таблице; иначе обёртка снимается, Блоки остаются подряд. Раскладка внутри Обёртки разворачивается в Блоки.

Свой Блок

Свой Блок потребителя (div[data-block="acme_card"], раздел «Свой Инструмент» страницы RichTextEditor) RichTextView показывает очищенным HTML и даёт ему тот же вертикальный ритм, что абзацу, — и больше ничего: отступ до следующего Блока такой же, как у абзаца на том же месте (в Документе, цитате, ячейке, колонке). Блоки внутри своего Блока-контейнера стоят с ритмом Документа, как в Обёртке; строчное содержимое (текст, атомы, Метки) отступов не получает. На листе surface="paper" — то же.

Вид — CSS потребителя по селектору data-block и токенам кита; полей стилей в описании Инструмента нет. Пишите стили на переменных темы — тогда Блок одинаков в Редакторе, в RichTextView и на листе paper, который переключает токены сам:

[data-block="acme_card"] {
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  padding: 0.75rem;
}

Вид узла из Части для движка (React-вид) в RichTextView не используется: движка здесь нет. Строчный атом показывается тем, что лежит в HTML, — пустой span без текста не виден; оживить Блок в показе можно Островом (раздел ниже). Ритм сверяет tests/rich-text-paper-css.test.tsx на настоящем каскаде.

Остров своего Блока

Свой Блок можно оживить в RichTextView — кнопкой, плеером, раскрывающимся блоком — клиентским компонентом, который описание Инструмента кладёт в поле island Блока. Остров нужен не всякому Блоку: без него Блок остаётся очищенным HTML («Свой Блок»).

// acme-player.tsx — модуль с директивой: сервер получит лишь ссылку на компонент
"use client";
import type { CustomIslandProps } from "@olddevs/ui/editor";

export function AcmePlayer({ attributes, text }: CustomIslandProps) {
  return <audio controls src={attributes.acme_src} aria-label={text} />;
}
// acme-tool.ts — описание Инструмента, общее у Редактора, очистки и Просмотра
import { AcmePlayer } from "./acme-player";

export const acmePlayer: CustomTool = {
  name: "acme_player",
  engine: [AcmePlayerNode],
  blocks: {
    acme_player: { kind: "block", attributes: { acme_src: { kind: "url" } }, island: AcmePlayer },
  },
};

// страница — серверный компонент
<RichTextView html={safeHtml} tools={[acmePlayer]} />;

Что получает компонент (CustomIslandProps): block — имя Блока, attributes — объявленные в attributes Блока атрибуты, которые есть в HTML, по именам без data- (acme_src; необъявленный атрибут компонент не видит), text — textContent Блока из серверного HTML. Значения — строки из очищенного HTML Документа: проверку по виду значения на сервере делает sanitizeRichText(html, DOMPurify, tools), и без неё Остров получит то, что лежит в html.

Как это устроено, и почему RichTextView остаётся серверным. Так же, как у «Копировать» (раздел выше): сам компонент без состояния, клиентский кусок — отдельный файл со своей директивой.

  1. Описание Инструмента целиком (Часть для движка, функции) через границу RSC не пройдёт, поэтому RichTextView выбирает из tools только Острова — и только тех Блоков, чей data-block="<имя>" есть в Документе (islandsFor, src/editor/viewIsland.ts) — и отдаёт острову ссылки на клиентские компоненты и имена атрибутов. Нет tools, нет island у Блока или Блока в Документе — острова нет вовсе: ни метки в разметке, ни эффекта, ни загрузки кода Острова потребителя (сервер сериализует ссылку на клиентский компонент только когда Блок есть). island обязан быть компонентом из модуля с "use client": серверный компонент или функция-загрузчик в клиентский остров не передаются.
  2. Серверный HTML Блока приходит всегда. До гидрации, до загрузки Острова и без JS читатель видит его — то есть содержимое Блока как оно сохранено.
  3. После монтирования остров (src/editor/BlockIsland.tsx и тело BlockIslandSlots.tsx, свой "use client") находит Блоки своего просмотра так же, как «Копировать» находит гнёзда: от своей метки <template> налево, а не по глобальному document — просмотр в теневом корне или iframe работает. В каждом вхождении Блока содержимое заменяется Островом; при смене tools (Остров пропал) или уходе Блока содержимое возвращается. Если Блок с Островом стоит внутри другого Блока с Островом, оживает внешний: компонент получает его текст целиком, а внутренний остаётся его содержимым.
  4. Состояние Острова не сбрасывается перерисовкой родителя: остров ищет Блоки заново только когда сменился Документ, а не tools (инлайновый литерал массива у родителя не перемонтирует Остров). Для этого RichTextView держит объект dangerouslySetInnerHTML стабильным (useMemo по Документу): новый объект на каждой перерисовке заставил бы React переписать разметку вместе со вставленными Островами и кнопками «Копировать».

Вид узла из движка в Просмотре не используется: Остров — отдельный компонент, а не React-вид Части для движка; Редактор поле island не читает.

Цена. Тело острова (BlockIslandSlots.tsx) подключается лениво (React.lazy) и в первую загрузку просмотра не входит: пока чанк идёт, виден серверный HTML Блока. В статический замер editorView попадает только оболочка и выбор Островов — около 0,2 кБ gzip; Tiptap в графе Просмотра по-прежнему нет, это проверяет tests/rich-text-view.test.tsx.

Доступность

Компонент не добавляет ролей: это обычный div с семантическим HTML внутри — заголовки, списки, цитата. Заголовки Документа начинаются с H2, если Редактор работал без heading1: страница сама держит H1. Документ с heading1 несёт собственный h1, и тогда страница своего второго не ставит. H1 в RichTextView рисуется той же типографикой, что в Редакторе. Если документ — отдельная область страницы, дайте контейнеру имя через aria-label и role="region".

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

  • Класть в html строку, не прошедшую серверную очистку.
  • Очищать только в браузере перед отправкой: сервер этого не увидит.
  • Брать RichTextEditor readOnly для страницы просмотра: движок на 130–170 кБ gzip ради текста.
  • Добавлять "use client" в файл, где RichTextView стоит один: он не нужен и уводит соседей в клиентский граф (остров с кнопкой «Копировать» — отдельный файл со своей директивой, он у RichTextView уже есть).
  • Передавать в island серверный компонент или функцию-загрузчик: через границу RSC проходит только ссылка на клиентский компонент из модуля с "use client".
  • Заводить свою кнопку копирования поверх RichTextView или читать текст из DOM блока: шапка и кнопка уже есть, а копируется textContent кода, не разметка.

Ссылки

  • Исходник: src/editor/RichTextView.tsx; шапка и кнопка — src/editor/codeHeader.ts (разметка шапки), src/editor/CodeCopyIsland.tsx (клиентский остров), src/editor/CodeCopyButton.tsx; Остров своего Блока — src/editor/viewIsland.ts (выбор Островов по Документу), src/editor/BlockIsland.tsx и src/editor/BlockIslandSlots.tsx (клиентский остров); лист surface="paper" — src/editor/surface.ts и src/editor/tokens.ts; типографика — src/editor/prose.ts, тема подсветки — src/editor/codeTheme.ts; серверная подсветка — src/editor/highlight.ts, вход — src/editor/highlight-entry.ts; Схема данными, sanitizeRichText и конфиг очистки — src/editor/schema.ts, лёгкий вход — src/editor/schema-entry.ts.
  • Тесты: tests/rich-text-view.test.tsx (в нём же граф модулей и сверка таблицы со Схемой), tests/rich-text-view-code-copy.test.tsx (шапка, остров, копирование), tests/rich-text-view-island.test.tsx (Остров своего Блока), tests/rich-text-paper.test.tsx и tests/rich-text-paper-css.test.tsx (лист surface="paper"), tests/editor-highlight-entry.test.ts (хелпер подсветки, экранирование, граф входа), tests/editor-sanitize.test.tsx (очистка настоящим DOMPurify: разметка Редактора без изменений, подделки, голый конфиг), tests/editor-sanitize-tools.test.tsx (очистка и richTextSchema со своими Инструментами), tests/rsc-gate.test.ts.
  • Storybook: Primitives/RichTextView.
  • Редактор — RichTextEditor; свои Инструменты, Остров и граница semver — API расширения Редактора; решения — ADR-0050, подсветка — ADR-0051, расширение — ADR-0058.

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