GrRichText
Берут, когда текст, который читатель увидит оформленным.
Обзор
Когда брать
- текст, который читатель увидит оформленным — описание, статья, объявление: абзацы, списки и выделение несут смысл, и терять их при сохранении нельзя;
- комментарий или заметка с минимальным набором —
schema="minimal": начертание, ссылка, список, и ничего, чем можно испортить страницу; - поле формы, а не отдельный экран — компонент читает
GrFormField, отдаёт значение в форму скрытым полем и подчиняетсяdisabled,readonly,invalidнаравне с остальными контролами; - свой набор возможностей —
extensionsдобавляет расширения TipTap к схеме, и компонент остаётся тем же полем со своим тулбаром и клавиатурой.
Когда взять другое
| Нужно | Берите |
|---|---|
| Простой текст в несколько строк, без разметки | GrTextarea |
| Строка в одну линию | GrInput |
| Показать текст в markdown, а не править его | GrMarkdown |
| Показать готовый код с подсветкой, а не править текст | GrCodeBlock |
| Набор своих меток-тегов, а не текст | GrInputTag |
Примеры 3 у каждого — живое демо и его код
Описание товара в объявлении
Объявлению нужна структура, поэтому схема —
article: заголовки со второго уровня, цитаты и код поверх основы, и ни в одной схеме нет h1 — он принадлежит странице. Поле читаетGrFormField, как любой контрол, — подпись,required,invalid, — аnameотправляет HTML с формой скрытым полем. Слотfooter— зона внутри рамки, а не строка под ней.Ответ по тикету
Схема
minimal— она же санитайзер: чего в ней нет — картинки, скрипта, таблицы, — то не переживает вставки.toolbar="both"держит панель сверху для клавиатуры и добавляет пузырёк у выделения для мыши.headerиfooterдержат адресатов и кнопку отправки внутри рамки, а пока ответ уходит, поле —disabled.Ticket #4182 · New address on invoices Обновление для страницы статуса
output="json"отдаёт документ TipTap как есть: CMS хранит структуру, а не разметку. Сохранённое показывается без редактора —markdownFromEditorDocumentпревращает документ в markdown, аGrMarkdownего читает, без потерь для любой из схем. Опубликованная заметка —readonly: её читают и копируют, но не правят.
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
size | GrRichTextSize | undefined | undefined | Ступень размера: кегль поля и кнопки тулбара. По умолчанию `md`; берётся из `GrConfigProvider`. |
ariaLabel | string | undefined | undefined | Доступное имя поля; перекрывает подпись `GrFormField`. |
modelValuev-model | string | Record<string, unknown> | null | undefined | undefined | Значение: строка HTML либо документ TipTap — по `output`. Форму задаёт проп, а не поведение пользователя: то же решение, что у `valueAdapter` в `granularity-chrono`. |
output | "html" | "json" | undefined | "html" | В каком виде значение уходит наружу. |
schema | GrRichTextSchemaName | undefined | "minimal" | Готовая схема: `minimal` или `article`. |
extensions | GrRichTextExtension[] | undefined | undefined | Свои расширения TipTap **в дополнение** к схеме — упоминания, картинки, таблица. Заменять набор схемы они не пытаются: тулбар строится по ней, и подмена оставила бы кнопки без команд. |
placeholder | string | undefined | undefined | Подсказка в пустом поле. |
toolbar | boolean | "bubble" | "both" | undefined | true | Где живут кнопки форматирования: панель сверху, пузырьковое меню у выделения, оба или ничего. |
id | string | undefined | undefined | Собственный `id` области ввода. Не задан — берётся из `GrFormField`. |
name | string | undefined | undefined | Имя для нативной формы: значение уходит скрытым полем. |
disabled | boolean | undefined | false | Поле недоступно: текст не правится, кнопки тулбара выключены. Наследуется от `GrFormField`. |
readonly | boolean | undefined | false | Значение видно и уходит в форму, но не редактируется. |
invalid | boolean | undefined | false | Визуальное и ARIA-состояние ошибки (`aria-invalid`). Наследуется от `GrFormField`. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). Наследуется от `GrFormField`. |
Слоты
| Slot | Type | Описание |
|---|---|---|
header | any | Полоса над областью ввода: подпись, счётчик, переключатель режима. Отдельная зона, а не место в тулбаре: тулбар строится по схеме и водит фокус ровером, а тут содержимое потребителя со своей клавиатурой. |
footer | any | Полоса под областью ввода: счётчик символов, подсказка, кнопки отправки. |
События
| 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 | Сигнатура | Описание |
|---|---|---|
editor | Editor | 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
Тулбар собран из 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
type GrRichTextSize = 'xs' | 'sm' | 'md' | 'lg';Safelist пуст: все классы поля лежат здесь целыми литералами, и granum
извлекает их сам — модуль уезжает в общий dist/chunks/, но сборка доходит
до него по графу бандла компонента. При UnoCSS-пресете, который общий чанк
не сканировал, без списка поле приезжало без рамки и фокус-кольца.
const grRichTextSafelist: string[];Файлdist/types/components/GrRichText/defaults.d.ts
Настраиваемые через GrConfigProvider пропы.
Аугментация реестра идёт по месту объявления — через
@feugene/granularity/composables/useGrComponentConfig, а не через реэкспорт:
иначе слияние отваливается молча.
interface GrRichTextConfigurableProps {
size?: GrRichTextSize;
}Файлdist/types/components/GrRichText/GrRichText.vue.d.ts
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;
}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 дают полосы над областью ввода и под ней, внутри той
же рамки:
<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:
<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 к схеме, а не заменяет её:
<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. Кнопку для своего расширения тулбар не покажет: он строится по схеме, а не по набору расширений; - без совместного редактирования: одновременная правка двумя людьми требует транспорта и разрешения конфликтов, а это не задача поля ввода.
Пропы этого примера