GrRichText

  • editor
  • Редактор
  • API · 14 props

Берут, когда текст, который читатель увидит оформленным.

import { GrRichText } from '@feugene/granularity-editor/components/GrRichText'

Обзор

Когда брать

  • текст, который читатель увидит оформленным — описание, статья, объявление: абзацы, списки и выделение несут смысл, и терять их при сохранении нельзя;
  • комментарий или заметка с минимальным набором — schema="minimal": начертание, ссылка, список, и ничего, чем можно испортить страницу;
  • поле формы, а не отдельный экран — компонент читает GrFormField, отдаёт значение в форму скрытым полем и подчиняется disabled, readonly, invalid наравне с остальными контролами;
  • свой набор возможностей — extensions добавляет расширения TipTap к схеме, и компонент остаётся тем же полем со своим тулбаром и клавиатурой.

Когда взять другое

НужноБерите
Простой текст в несколько строк, без разметкиGrTextarea
Строка в одну линиюGrInput
Показать текст в markdown, а не править егоGrMarkdown
Показать готовый код с подсветкой, а не править текстGrCodeBlock
Набор своих меток-тегов, а не текстGrInputTag

Примеры 3 у каждого — живое демо и его код

  • Описание товара в объявлении

    Объявлению нужна структура, поэтому схема — article: заголовки со второго уровня, цитаты и код поверх основы, и ни в одной схеме нет h1 — он принадлежит странице. Поле читает GrFormField, как любой контрол, — подпись, required, invalid, — а name отправляет HTML с формой скрытым полем. Слот footer — зона внутри рамки, а не строка под ней.

    Halden desk · listingShown on the product page under the gallery
    210 characters · headings, lists and quotes keep their look on the page
  • Ответ по тикету

    Схема minimal — она же санитайзер: чего в ней нет — картинки, скрипта, таблицы, — то не переживает вставки. toolbar="both" держит панель сверху для клавиатуры и добавляет пузырёк у выделения для мыши. header и footer держат адресатов и кнопку отправки внутри рамки, а пока ответ уходит, поле — disabled.

    Ticket #4182 · New address on invoices
    Select text to format it
  • Обновление для страницы статуса

    output="json" отдаёт документ TipTap как есть: CMS хранит структуру, а не разметку. Сохранённое показывается без редактора — markdownFromEditorDocument превращает документ в markdown, а GrMarkdown его читает, без потерь для любой из схем. Опубликованная заметка — readonly: её читают и копируют, но не правят.

    INC-2291 · update
    status.northwind.example

    Card payments are delayed

    Since 22:40 UTC some card payments take up to 30 minutes to confirm. SEPA is not affected.

    • No payment is lost or charged twice.
    • Invoices are marked paid once confirmed.

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
sizeGrRichTextSize | undefinedundefinedСтупень размера: кегль поля и кнопки тулбара. По умолчанию `md`; берётся из `GrConfigProvider`.
ariaLabelstring | undefinedundefinedДоступное имя поля; перекрывает подпись `GrFormField`.
modelValuev-modelstring | Record<string, unknown> | null | undefinedundefinedЗначение: строка HTML либо документ TipTap — по `output`. Форму задаёт проп, а не поведение пользователя: то же решение, что у `valueAdapter` в `granularity-chrono`.
output"html" | "json" | undefined"html"В каком виде значение уходит наружу.
schemaGrRichTextSchemaName | undefined"minimal"Готовая схема: `minimal` или `article`.
extensionsGrRichTextExtension[] | undefinedundefinedСвои расширения TipTap **в дополнение** к схеме — упоминания, картинки, таблица. Заменять набор схемы они не пытаются: тулбар строится по ней, и подмена оставила бы кнопки без команд.
placeholderstring | undefinedundefinedПодсказка в пустом поле.
toolbarboolean | "bubble" | "both" | undefinedtrueГде живут кнопки форматирования: панель сверху, пузырьковое меню у выделения, оба или ничего.
idstring | undefinedundefinedСобственный `id` области ввода. Не задан — берётся из `GrFormField`.
namestring | undefinedundefinedИмя для нативной формы: значение уходит скрытым полем.
disabledboolean | undefinedfalseПоле недоступно: текст не правится, кнопки тулбара выключены. Наследуется от `GrFormField`.
readonlyboolean | undefinedfalseЗначение видно и уходит в форму, но не редактируется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки (`aria-invalid`). Наследуется от `GrFormField`.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`). Наследуется от `GrFormField`.

Слоты

SlotTypeОписание
headeranyПолоса над областью ввода: подпись, счётчик, переключатель режима. Отдельная зона, а не место в тулбаре: тулбар строится по схеме и водит фокус ровером, а тут содержимое потребителя со своей клавиатурой.
footeranyПолоса под областью ввода: счётчик символов, подсказка, кнопки отправки.

События

EventСигнатураОписание
update:modelValue(event: "update:modelValue", value: string | Record<string, unknown>): void—
change(event: "change", value: string | Record<string, unknown>): void—
focus(event: "focus"): void—
blur(event: "blur"): void—

Методы

MethodsСигнатураОписание
editorEditor | nullИнстанс TipTap: своя команда, своё расширение, свой плагин.
focus() => boolean | undefinedПоставить курсор в поле. До монтирования редактора ничего не делает.
blur() => boolean | undefinedУбрать фокус из поля.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrRichTextSize, GrRichTextConfigurableProps, GrRichTextProps } from '@feugene/granularity-editor/components/GrRichText'

Файлdist/types/components/GrRichText/config.d.ts

grRichTextConfigconst

Тулбар собран из GrButton ядра, пузырьковое меню — из GrPopover. Оба ребра объявлены: granum подмешивает safelist и CSS только тем компонентам, что попали в селекцию, и без графа потребитель, выбравший один GrRichText, получил бы панель без кнопок и всплывающее меню без подложки. Собственный CSS в cssFiles не объявлен, и объявлять его не нужно: granumProvider() находит styles.css рядом с компонентом сам и пишет его в манифест — оттуда его и забирает приложение.

const grRichTextConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrRichText">;

Файлdist/types/components/GrRichText/grRichTextStyles.d.ts

GrRichTextSizetype
type GrRichTextSize = 'xs' | 'sm' | 'md' | 'lg';
grRichTextSafelistconst

Safelist пуст: все классы поля лежат здесь целыми литералами, и granum извлекает их сам — модуль уезжает в общий dist/chunks/, но сборка доходит до него по графу бандла компонента. При UnoCSS-пресете, который общий чанк не сканировал, без списка поле приезжало без рамки и фокус-кольца.

const grRichTextSafelist: string[];

Файлdist/types/components/GrRichText/defaults.d.ts

GrRichTextConfigurablePropsinterface

Настраиваемые через GrConfigProvider пропы. Аугментация реестра идёт по месту объявления — через @feugene/granularity/composables/useGrComponentConfig, а не через реэкспорт: иначе слияние отваливается молча.

interface GrRichTextConfigurableProps {
    size?: GrRichTextSize;
}

Файлdist/types/components/GrRichText/GrRichText.vue.d.ts

GrRichTextPropsinterface

GrRichText — поле форматированного текста. Разметку хранит и разбирает ProseMirror, и это же **и есть санитайзер**: содержимое разбирается по схеме, узлы и марки вне её отбрасываются, а на выход документ сериализуется из того же дерева. <script> не переживает разбора — поэтому отдельного санитайзера в пакете нет. Что вместо него не сделано: пакет не показывает чужой HTML. Он поле ввода, а не публикация, и v-html в нём отсутствует намеренно — это была бы единственная XSS-поверхность ровно там, где данные приходят от пользователя.

interface GrRichTextProps {
    /**
     * Значение: строка HTML либо документ TipTap — по `output`.
     *
     * Форму задаёт проп, а не поведение пользователя: то же решение, что у
     * `valueAdapter` в `granularity-chrono`.
     */
    modelValue?: string | Record<string, unknown> | null;
    /** В каком виде значение уходит наружу. */
    output?: 'html' | 'json';
    /** Готовая схема: `minimal` или `article`. */
    schema?: GrRichTextSchemaName;
    /**
     * Свои расширения TipTap **в дополнение** к схеме — упоминания, картинки,
     * таблица. Заменять набор схемы они не пытаются: тулбар строится по ней, и
     * подмена оставила бы кнопки без команд.
     */
    extensions?: GrRichTextExtension[];
    /** Подсказка в пустом поле. */
    placeholder?: string;
    /**
     * Где живут кнопки форматирования: панель сверху, пузырьковое меню у
     * выделения, оба или ничего.
     */
    toolbar?: boolean | 'bubble' | 'both';
    /**
     * Ступень размера: кегль поля и кнопки тулбара. По умолчанию `md`; берётся из
     * `GrConfigProvider`.
     */
    size?: GrRichTextSize;
    /** Собственный `id` области ввода. Не задан — берётся из `GrFormField`. */
    id?: string;
    /** Имя для нативной формы: значение уходит скрытым полем. */
    name?: string;
    /** Поле недоступно: текст не правится, кнопки тулбара выключены. Наследуется от `GrFormField`. */
    disabled?: boolean;
    /** Значение видно и уходит в форму, но не редактируется. */
    readonly?: boolean;
    /** Визуальное и ARIA-состояние ошибки (`aria-invalid`). Наследуется от `GrFormField`. */
    invalid?: boolean;
    /** Обязательное поле (`aria-required`). Наследуется от `GrFormField`. */
    required?: boolean;
    /** Доступное имя поля; перекрывает подпись `GrFormField`. */
    ariaLabel?: string;
}
GrRichTextEmitsinterface
interface GrRichTextEmits {
    /** Значение изменилось (`v-model`) — в форме из `output`. */
    (e: 'update:modelValue', value: string | Record<string, unknown>): void;
    /** Значение изменилось; приходит вместе с `update:modelValue` на каждой правке. */
    (e: 'change', value: string | Record<string, unknown>): void;
    /** Фокус вошёл в область ввода. */
    (e: 'focus'): void;
    /** Фокус ушёл из области ввода. */
    (e: 'blur'): void;
}

Использование из прозы библиотеки

Схема — она же санитайзер

Отдельного санитайзера в пакете нет, и это не упущение. ProseMirror разбирает вход по схеме: узел или марка, которых в ней нет, отбрасываются при разборе, а на выход документ сериализуется из того же дерева. <script>, <iframe> и <img> не переживают вставки — проверено тестом, а не обещано.

Отсюда следствие, которое стоит знать: чего нет в схеме, того не будет и в значении. Вставили статью с картинками в поле со схемой minimal — останется текст. Это осознанный размен: поле, принимающее произвольную разметку, ломает вёрстку страницы, на которой её потом покажут.

Две готовые схемы

minimal — начертание, ссылка, список. article добавляет структуру: заголовки, цитату, блок кода.

Заголовка первого уровня не даёт ни одна. h1 на странице один и принадлежит ей, а не полю ввода внутри неё; редактор, позволяющий вставить второй, ломает структуру документа у того, кто просто печатал текст.

Тулбар собран из схемы, а не написан разметкой

Кнопки строятся по списку действий самой схемы. Напиши их руками — и первая же правка схемы разошлась бы с панелью молча: кнопка осталась, а команды за ней больше нет.

Панель переносится группами целиком, а не кнопка за кнопкой. flex-wrap по отдельным кнопкам рвал бы ряд там, где кончилось место, и разлучал бы кнопки одного смысла: «Заголовок» в одной строке, «Подзаголовок» в другой, причём без разделителя между ними. Обёртка группы этого не допускает — на узком поле строки читаются теми же блоками, что и один ряд.

Тулбар — role="toolbar" с одной остановкой Tab: внутри ходят стрелками. Иначе до самого текста пришлось бы добираться десятком нажатий — у «статьи» десять кнопок. Активный формат объявлен aria-pressed, а не только подсветкой: подсветка для скринридера не существует.

Пузырёк держится на фокусе в тексте

toolbar="bubble" ставит панель у выделения, "both" — и сверху, и у выделения.

Пузырёк живёт ровно пока в поле есть непустое выделение и фокус: ухода фокуса достаточно, чтобы он погас. Отсюда два неочевидных следствия, и оба сидят в коде:

  • панель не забирает фокус при открытии — иначе гасила бы себя в том же кадре, в котором открылась;
  • кнопка пузырька отменяет mousedown. Без этого фокус уходил бы на кнопку, и пузырёк исчезал под курсором после первого же формата — второй подряд применить нечем. Панели сверху это не нужно: она от фокуса не зависит.

Кнопки пузырька — всегда самой мелкой ступени, независимо от размера поля. Панель сверху живёт в раме поля и растёт вместе с ним, а пузырёк висит над текстом, который читают, и там уместна наименьшая площадь, дающая цель нажатия 28×28 — выше требуемых WCAG 2.2 двадцати четырёх. Есть и арифметическая причина: панель поповера ограничена по ширине, и десяток кнопок ступени поля в неё не помещался.

Esc пузырёк закрывает, клик вне — нет: слушать пришлось бы click, а именно им заканчивается протяжка выделения, и панель закрывалась бы ровно в тот момент, когда должна появиться.

Якорь пузырька — прямоугольник выделения в координатах вьюпорта, а прокрутка колесом выделение не меняет. Поэтому, пока пузырёк включён, якорь пересчитывается на scroll и resize — scroll слушается в фазе захвата на window, чтобы ловить и прокрутку вложенной области, а не только страницы. Выделение, ушедшее за видимую часть поля или страницы, пузырёк прячет, а не оставляет висеть над чужим содержимым; вернулось — пузырёк снова рядом. Закрытый Esc пузырёк прокрутка не возвращает.

Видимая часть урезается и scroll-padding каждой области прокрутки, страницы тоже: так сайт объявляет липкую шапку (html { scroll-padding-top: 80px }). Выделение под шапкой пузырёк прячет; у самой её кромки, где над выделением места нет, пузырёк встаёт под ним, а не на шапку. Сторону выбирает поле — GrPopover о scroll-padding не знает и отступает только от края вьюпорта.

Показывать ли и с какой стороны — два разных вопроса с двумя разными областями. Видно ли выделение, решают все обрезающие предки, поле включительно: выделение, уехавшее за край своей прокрутки, пузырёк прячет. А место для пузырька — страница вне поля: пузырёк живёт в верхнем слое, и поле его не обрезает. Своя прокрутка поля (.ProseMirror с overflow-y: auto) и его рамка в счёт не идут — пузырёк ложится на тулбар и поле над первой строкой, а вниз уходит, только когда над выделением нет места на странице, например у липкой шапки. Иначе он закрывал бы следующую строку, которую читают.

Клавиатурный путь к форматам в этом режиме — горячие клавиши: панель у выделения остановкой Tab не является. Нужны обе дороги — toolbar="both".

Шапка и подвал — зоны поля, а не блоки рядом

Слоты #header и #footer дают полосы над областью ввода и под ней, внутри той же рамки:

vue
<GrRichText v-model="value">
  <template #header>Кому: <strong>[email protected]</strong></template>
  <template #footer>Знаков: {{ length }}</template>
</GrRichText>

Раньше зон было ровно две — тулбар и текст, — поэтому подпись со счётчиком ставили снаружи рамки. Визуально они оказывались отдельным элементом: рамка обводила только текст, и связь с полем держалась на близости, а не на разметке.

Зоны отбиты рамкой изнутри, как тулбар, — линия принадлежит границе между зонами, а не самой зоне: у края поля она сошлась бы со скруглением рамки. Фона у них нет намеренно: --gr-muted подложен тулбару, потому что он панель управления, а здесь содержимое потребителя, и вторая подложка спорила бы с ним.

Клавиатура зон — забота потребителя: тулбар водит фокус ровером по своим кнопкам, а что положено в шапку, компонент не знает.

Форма значения задаётся пропом

output="html" (по умолчанию) — строка разметки; output="json" — документ TipTap. Тот же приём, что у valueAdapter в granularity-chrono: форму значения выбирает потребитель, а не поведение пользователя.

В нативную форму значение уходит строкой в любом режиме: скрытое поле не умеет объектов.

Сохранённое показывается без редактора

Поле нужно, пока текст правят. Показать сохранённое оно — не лучший выбор: ProseMirror требует DOM, поэтому на сервере не печатается ничего (см. следующий раздел), а в браузере ради одного абзаца поднимается редактируемый документ с плагинами, транзакциями и историей.

Документ TipTap переводится в markdown и отдаётся GrMarkdown:

vue
<script setup lang="ts">
import { computed } from 'vue'
import { markdownFromEditorDocument } from '@feugene/granularity-editor/markdown'
import GrMarkdown from '@feugene/granularity-editor/components/GrMarkdown'

const { document } = defineProps<{ document: object }>()
const source = computed(() => markdownFromEditorDocument(document))
</script>

<template>
  <GrMarkdown :source="source" />
</template>

Потерь на этом пути нет ни у одной из схем. Сериализатор знает каждый узел и каждую марку, которые они способны выдать, — вплоть до переноса строки по Shift+Enter; картинку он тоже сериализует, хотя ни одна схема её пока не даёт. Заводя своё расширение с новым узлом, ветку в сериализаторе придётся завести вместе с ним: молча потеряется именно то, что вы добавили.

Он же годится и для хранения: output="json" держит документ TipTap, а markdownFromEditorDocument кладёт в базу markdown — форму, которую прочтёт и человек, и любой другой инструмент.

Содержимое не печатается на сервере

ProseMirror требует DOM, поэтому редактор поднимается после монтирования, а серверная разметка — оболочка с data-allow-mismatch. Текста в ней нет, но место есть: хост держит болванку с тем же боксом, что займёт область ввода, — высоты --gr-rich-text-min-height и --gr-rich-text-max-height у них общие, одним правилом. Поле с min = max на гидрации не двигается; значение выше min-height при открытом max-height вырастает под текст после монтирования — см. ../ssr.md.

v-html в пакете нет намеренно: печатать чужую разметку ради первого кадра значило бы завести единственную XSS-поверхность ровно там, где данные приходят от пользователя. Поле — это ввод, а не публикация.

Свои расширения

extensions добавляет расширения TipTap к схеме, а не заменяет её:

vue
<script setup lang="ts">
import { CharacterCount } from '@tiptap/extensions'
</script>

<template>
  <GrRichText v-model="text" :extensions="[CharacterCount.configure({ limit: 500 })]" />
</template>

Готовые расширения перечислены в каталоге TipTap, своё пишется по руководству. Пакет их не оборачивает: что отдали, то и получит редактор.

Смена набора — как и смена schema — пересобирает редактор: схема ProseMirror неизменяема, из неё выведены и документ, и команды. Текст переносится разметкой и проходит разбор заново, поэтому узел, которого в новой схеме нет, отбрасывается — то же правило, что и при вставке.

Кнопку для своего расширения тулбар не покажет: он строится по схеме, и кнопка без команды за ней была бы обманом. Своя кнопка ставится слотом #action-<key> либо собственной панелью поверх инстанса из defineExpose.

Границы
  • не CMS: ни картинок, ни упоминаний, ни таблиц в этом релизе. Расширение TipTap потребитель добавляет сам через extensions, а инстанс редактора компонент отдаёт через defineExpose. Кнопку для своего расширения тулбар не покажет: он строится по схеме, а не по набору расширений;
  • без совместного редактирования: одновременная правка двумя людьми требует транспорта и разрешения конфликтов, а это не задача поля ввода.