GrRichText
Берут, когда текст, который читатель увидит оформленным.
Когда брать
- текст, который читатель увидит оформленным — описание, статья, объявление: абзацы, списки и выделение несут смысл, и терять их при сохранении нельзя;
- комментарий или заметка с минимальным набором —
schema="minimal": начертание, ссылка, список, и ничего, чем можно испортить страницу; - поле формы, а не отдельный экран — компонент читает
GrFormField, отдаёт значение в форму скрытым полем и подчиняетсяdisabled,readonly,invalidнаравне с остальными контролами; - свой набор возможностей —
extensionsдобавляет расширения TipTap к схеме, и компонент остаётся тем же полем со своим тулбаром и клавиатурой.
Когда взять другое
| Нужно | Берите |
|---|---|
| Простой текст в несколько строк, без разметки | GrTextarea |
| Строка в одну линию | GrInput |
| Показать готовый код с подсветкой, а не править текст | GrCodeBlock |
| Набор своих меток-тегов, а не текст | GrInputTag |
Схема — она же санитайзер
Отдельного санитайзера в пакете нет, и это не упущение. 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, а именно им заканчивается
протяжка выделения, и панель закрывалась бы ровно в тот момент, когда должна появиться.
Клавиатурный путь к форматам в этом режиме — горячие клавиши: панель у выделения остановкой 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, поэтому редактор поднимается после монтирования, а серверная разметка —
пустая оболочка с data-allow-mismatch.
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. Кнопку для своего расширения тулбар не покажет: он строится по схеме, а не по набору расширений; - без совместного редактирования: одновременная правка двумя людьми требует транспорта и разрешения конфликтов, а это не задача поля ввода;
- без просмотрщика markdown: показ сохранённого текста закрывает отдельный компонент, и он ещё не написан.
Установка
npm i @feugene/granularity-editorИмпорт
import { GrRichText } from '@feugene/granularity-editor/components/GrRichText'API
API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.
Примеры 5
Basic
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrRichTextSize } from '@feugene/granularity-editor'
// `GrRichText`, `GrFormField`, `GrRadioGroup` подставляются авто-импортом.
/**
* Поле с тулбаром и небольшой конструктор под ним.
*
* Панель модели тут не для красоты: значение — размеченный текст, и увидеть, что
* именно уходит наружу, иначе нельзя. Переключатель `output` меняет **форму**
* этого значения, и разница видна в той же панели.
*/
const value = ref<string | Record<string, unknown>>('<p>Наберите текст и примените <strong>формат</strong>.</p>')
const size = ref<GrRichTextSize>('md')
const toolbar = ref<'true' | 'false' | 'bubble' | 'both'>('true')
const output = ref<'html' | 'json'>('html')
const sizeOptions = [
{ value: 'xs', label: 'XS' },
{ value: 'sm', label: 'SM' },
{ value: 'md', label: 'MD' },
{ value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrRichTextSize, label: string }>
const toolbarOptions = [
{ value: 'true', label: 'Панель' },
{ value: 'bubble', label: 'Пузырёк' },
{ value: 'both', label: 'Оба' },
{ value: 'false', label: 'Нет' },
] satisfies Array<{ value: 'true' | 'false' | 'bubble' | 'both', label: string }>
const outputOptions = [
{ value: 'html', label: 'HTML' },
{ value: 'json', label: 'JSON' },
] satisfies Array<{ value: 'html' | 'json', label: string }>
/** `toolbar` принимает и булево, и строку — радиогруппа отдаёт только строки. */
const toolbarProp = computed(() => {
if (toolbar.value === 'true')
return true
if (toolbar.value === 'false')
return false
return toolbar.value
})
const model = computed(() => (typeof value.value === 'string'
? value.value
: JSON.stringify(value.value, null, 2)))
/**
* Форма значения меняется вместе с `output`: старое значение остаётся в прежнем
* виде до первой правки, и компонент об этом честно предупреждает в консоли.
* Поэтому переключатель сразу приводит модель к новой форме.
*/
function onOutputChange(next: 'html' | 'json'): void {
output.value = next
value.value = next === 'json'
? { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Наберите текст.' }] }] }
: '<p>Наберите текст.</p>'
}
</script>
<template>
<div class="grid gap-4">
<GrRichText
v-model="value"
schema="article"
:size="size"
:toolbar="toolbarProp"
:output="output"
aria-label="Описание"
/>
<div class="showcase-demo-panel grid gap-4 rounded-[var(--gr-radius-lg)] border p-4 sm:grid-cols-3">
<GrFormField label="size">
<GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="toolbar">
<GrRadioGroup v-model="toolbar" :options="toolbarOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="output">
<GrRadioGroup
:model-value="output"
:options="outputOptions"
variant="button"
size="sm"
@update:model-value="onOutputChange($event as 'html' | 'json')"
/>
</GrFormField>
</div>
<pre class="max-h-64 overflow-auto rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] p-3 text-[length:var(--gr-control-text-sm)] leading-[var(--gr-leading-sm)]">{{ model }}</pre>
<p class="showcase-demo-text text-sm">
<strong>output</strong> меняет форму значения, а не поведение: <code>html</code> отдаёт строку
разметки, <code>json</code> — документ TipTap. В нативную форму значение уходит строкой в любом
режиме: скрытое поле не умеет объектов.
</p>
<p class="showcase-demo-text text-sm">
<strong>toolbar</strong> решает, где живут кнопки: панель сверху, пузырёк у выделения, оба или
ничего. Выделите фрагмент в режиме «Пузырёк» — панель появится у самого текста. Горячие клавиши
работают всегда: <strong>Ctrl/Cmd + B</strong> и <strong>I</strong>.
</p>
<p class="showcase-demo-text text-sm">
Тулбар — одна остановка <strong>Tab</strong>, внутри ходят стрелками: у «статьи» десять кнопок,
и без этого до самого текста пришлось бы добираться десятью нажатиями. Активный формат объявлен
<code>aria-pressed</code>, а не только подсветкой — подсветки скринридер не видит.
</p>
</div>
</template>Extensions
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import { CharacterCount, Focus, Selection } from '@tiptap/extensions'
import type { GrRichTextExtension } from '@feugene/granularity-editor'
/**
* Свои расширения TipTap поверх схемы.
*
* Переключатели включают их **на живом поле**: смена набора пересобирает
* редактор, а текст переносится разметкой и проходит разбор по новой схеме.
* Схему ProseMirror подменить нельзя — из неё выведены и документ, и команды.
*/
const value = ref('<p>Включите расширение и продолжайте печатать.</p>')
const LIMIT = 120
const catalogue = [
{
key: 'characterCount',
title: 'CharacterCount',
about: `Счётчик символов и потолок. Здесь предел — ${LIMIT}: дальше ввод просто не проходит.`,
make: () => CharacterCount.configure({ limit: LIMIT }),
},
{
key: 'focus',
title: 'Focus',
about: 'Помечает абзац под курсором классом `has-focus`. Оформление — ваше: здесь это полоса слева.',
make: () => Focus.configure({ className: 'has-focus', mode: 'shallowest' }),
},
{
key: 'selection',
title: 'Selection',
about: 'Оставляет выделение видимым, когда фокус ушёл из поля. Выделите текст и щёлкните мимо.',
make: () => Selection,
},
] as const
type ExtensionKey = typeof catalogue[number]['key']
const enabled = ref<ExtensionKey[]>([])
const extensions = computed<GrRichTextExtension[]>(() => (
catalogue
.filter(entry => enabled.value.includes(entry.key))
.map(entry => entry.make() as GrRichTextExtension)
))
/** Инстанс редактора наружу отдаёт сам компонент — счётчик живёт в нём. */
const field = shallowRef<{ editor: { storage: Record<string, { characters?: () => number }> } } | null>(null)
const typed = ref(0)
// `flush: 'post'` — не педантизм: включение расширения пересобирает редактор в
// собственном наблюдателе компонента, и до этого момента счётчика в хранилище
// ещё нет. Без задержки поле показывало «0» при непустом тексте.
watch([value, enabled], () => {
const storage = field.value?.editor?.storage?.characterCount
typed.value = typeof storage?.characters === 'function' ? storage.characters() : 0
}, { flush: 'post' })
const counted = computed(() => enabled.value.includes('characterCount'))
</script>
<template>
<div class="showcase-editor-extensions grid gap-4">
<GrRichText
ref="field"
v-model="value"
schema="article"
:extensions="extensions"
aria-label="Текст с расширениями"
/>
<div class="showcase-demo-panel grid gap-3 rounded-[var(--gr-radius-lg)] border p-4">
<div class="showcase-demo-title text-sm font-semibold">
Расширения
</div>
<label v-for="entry in catalogue" :key="entry.key" class="flex items-start gap-3">
<GrCheckbox
:model-value="enabled.includes(entry.key)"
:aria-label="entry.title"
@update:model-value="enabled = $event ? [...enabled, entry.key] : enabled.filter(k => k !== entry.key)"
/>
<span class="grid gap-0.5">
<code class="text-[length:var(--gr-control-text-sm)] leading-[var(--gr-control-leading-sm)]">{{ entry.title }}</code>
<span class="showcase-demo-text text-sm">{{ entry.about }}</span>
</span>
</label>
<p v-if="counted" class="showcase-demo-text text-sm">
Набрано символов: <strong>{{ typed }}</strong> из {{ LIMIT }}
</p>
</div>
<p class="showcase-demo-text text-sm">
Набор расширений задаётся пропом <code>extensions</code> и добавляется <strong>к схеме</strong>,
а не заменяет её. Кнопку для своего расширения тулбар не покажет: он строится по схеме, и
кнопка без команды за ней была бы обманом.
</p>
<p class="showcase-demo-text text-sm">
Смена набора пересобирает редактор: схема ProseMirror неизменяема — из неё выведены и документ,
и команды. Текст переносится разметкой и проходит разбор заново, поэтому узел, которого в новой
схеме нет, отбрасывается — то же правило, что и при вставке.
</p>
<p class="showcase-demo-text text-sm">
<code>Focus</code> и <code>Selection</code> сами ничего не рисуют — они вешают класс, а
оформление остаётся за вами. В этом демо классы оформлены парой правил рядом; без них
расширение честно работает, но выглядит как выключенное.
</p>
<p class="showcase-demo-text text-sm">
<code>TrailingNode</code> в списке нет намеренно: он уже входит в <code>StarterKit</code>, то
есть в саму схему. Добавить его пропом можно, но переключатель ничего бы не менял — под
заголовком и цитатой пустой абзац есть и без него.
</p>
<p class="showcase-demo-text text-sm">
Полный список готовых расширений —
<GrLink href="https://tiptap.dev/docs/editor/extensions" external>каталог TipTap</GrLink>; как
написать своё —
<GrLink href="https://tiptap.dev/docs/editor/extensions/custom-extensions" external>руководство по расширениям</GrLink>.
Пакет ничего в них не оборачивает: <code>extensions</code> принимает их как есть, а инстанс
редактора компонент отдаёт через <code>defineExpose</code> — для своих команд и плагинов.
</p>
</div>
</template>
<!--
Классы вешают сами расширения, а рисует их потребитель — в этом и смысл
`Focus` и `Selection`. Стиль не `scoped`: узлы создаёт ProseMirror в рантайме,
атрибут области видимости на них не попадает.
-->
<style>
.showcase-editor-extensions .has-focus {
border-left: 2px solid var(--gr-primary);
padding-left: 0.5rem;
margin-left: -0.625rem;
}
.showcase-editor-extensions .selection {
background: var(--gr-accent);
border-radius: var(--gr-radius-sm);
}
</style>Frame
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrButton } from '@feugene/granularity'
/**
* Шапка и подвал поля — зоны внутри рамки, а не блоки рядом с ней.
*
* Снаружи подпись и счётчик читались отдельным элементом: рамка обводила только
* текст, и связь с полем держалась на близости. Здесь они внутри той же рамки и
* отбиты линией, как тулбар.
*/
const value = ref('<p>Черновик письма клиенту.</p>')
const plainLength = computed(() => value.value.replace(/<[^>]*>/g, '').length)
</script>
<template>
<div class="grid gap-4">
<GrRichText v-model="value" schema="article" aria-label="Письмо">
<template #header>
<div class="flex items-center justify-between gap-2">
<span class="text-[length:var(--gr-control-text-sm)] leading-[var(--gr-control-leading-sm)]">
Кому: <strong>[email protected]</strong>
</span>
<GrBadge tone="warning">Черновик</GrBadge>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between gap-2">
<span class="showcase-demo-text text-sm">Знаков: {{ plainLength }}</span>
<GrButton size="xs" variant="outline">Отправить</GrButton>
</div>
</template>
</GrRichText>
<p class="showcase-demo-text text-sm">
Обе зоны необязательны и включаются самим фактом слота. Линия принадлежит границе между
зонами, а не самой зоне: у края поля она сошлась бы со скруглением рамки.
</p>
</div>
</template>Schema
<script setup lang="ts">
import { ref } from 'vue'
/**
* Схема — она же санитайзер.
*
* Одно и то же значение в двух схемах: слева «минимум», справа «статья».
* Вставка одинаковая, результат разный — и это не фильтр поверх, а разбор.
*/
const DIRTY = '<h2>Заголовок</h2><p>Текст с <strong>форматом</strong>.</p>'
+ '<blockquote><p>Цитата</p></blockquote>'
+ '<script>alert(1)<\/script><iframe src="https://example.com"></iframe>'
const minimal = ref(DIRTY)
const article = ref(DIRTY)
</script>
<template>
<div class="grid gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-2">
<span class="showcase-demo-text text-sm font-semibold">minimal</span>
<GrRichText v-model="minimal" schema="minimal" aria-label="Минимальная схема" />
</div>
<div class="grid gap-2">
<span class="showcase-demo-text text-sm font-semibold">article</span>
<GrRichText v-model="article" schema="article" aria-label="Схема статьи" />
</div>
</div>
<p class="showcase-demo-text text-sm">
В оба поля пришло одно и то же значение — с заголовком, цитатой, <code><script></code> и
<code><iframe></code>. Слева осталась только строчная разметка, справа — ещё заголовок и
цитата. Скрипта и фрейма нет нигде: <strong>узлы вне схемы не переживают разбора</strong>.
</p>
<p class="showcase-demo-text text-sm">
Отдельного санитайзера в пакете поэтому нет. Разбор идёт по схеме, а на выход документ
сериализуется из того же дерева — очистка получается тем же механизмом, ради которого редактор
и выбран. Обратная сторона: чего нет в схеме, того не будет и в значении.
</p>
</div>
</template>Toolbar
<script setup lang="ts">
import { computed, ref } from 'vue'
import { createSchema, type GrRichTextAction } from '@feugene/granularity-editor'
/**
* Что тулбар умеет из коробки — полным списком.
*
* Таблица строится из той же схемы, по которой собирается панель: разойтись они
* не могут по построению. Допиши действие в схему — строка появится сама.
*/
const value = ref('<h2>Попробуйте кнопки</h2><p>Выделите фрагмент и примените формат.</p>')
const minimal = createSchema('minimal').actions
const article = createSchema('article').actions
const groupTitles: Record<GrRichTextAction['group'], string> = {
inline: 'Начертание',
block: 'Структура',
list: 'Списки',
}
/** `Mod` — `⌘` на Apple и `Ctrl` на остальных: показываем обе записи. */
function shortcut(action: GrRichTextAction): string {
return action.shortcut.replace('Mod', '⌘/Ctrl').replace(/-/g, ' + ')
}
function inMinimal(action: GrRichTextAction): boolean {
return minimal.some(entry => entry.key === action.key)
}
const rows = computed(() => article.map(action => ({
action,
group: groupTitles[action.group],
shortcut: shortcut(action),
schemas: inMinimal(action) ? 'minimal, article' : 'article',
})))
</script>
<template>
<div class="grid gap-4">
<GrRichText v-model="value" schema="article" toolbar="both" aria-label="Все кнопки" />
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[length:var(--gr-control-text-sm)] leading-[var(--gr-control-leading-sm)]">
<thead>
<tr class="border-b border-[var(--gr-brd)] text-left">
<th class="py-2 pr-3 font-semibold">Кнопка</th>
<th class="py-2 pr-3 font-semibold">Группа</th>
<th class="py-2 pr-3 font-semibold">Команда TipTap</th>
<th class="py-2 pr-3 font-semibold">Клавиши</th>
<th class="py-2 font-semibold">Схемы</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.action.key" class="border-b border-[var(--gr-brd)]">
<td class="py-2 pr-3">{{ row.action.labelFallback }}</td>
<td class="showcase-demo-text py-2 pr-3">{{ row.group }}</td>
<td class="py-2 pr-3"><code>{{ row.action.command }}</code></td>
<td class="py-2 pr-3"><code>{{ row.shortcut }}</code></td>
<td class="showcase-demo-text py-2">{{ row.schemas }}</td>
</tr>
</tbody>
</table>
</div>
<p class="showcase-demo-text text-sm">
Таблица построена из той же схемы, по которой собирается панель: разойтись они не могут по
построению. Кнопка без команды за ней тут невозможна — это и есть причина, по которой тулбар
описан данными, а не написан разметкой.
</p>
<p class="showcase-demo-text text-sm">
Горячие клавиши приходят от расширений TipTap, а не от пакета: они работают и при
<code>toolbar="false"</code>. Кроме перечисленного из коробки идут отмена и повтор
(<code>⌘/Ctrl + Z</code> и <code>⌘/Ctrl + Shift + Z</code>), перенос строки внутри абзаца
(<code>Shift + Enter</code>), горизонтальная черта и ссылка — последние две без своей кнопки:
черта ставится правилом ввода <code>---</code>, ссылка живёт маркой и ждёт своего интерфейса.
</p>
<p class="showcase-demo-text text-sm">
Схема <code>minimal</code> оставляет только начертание и списки, <code>article</code> добавляет
структуру. Заголовка первого уровня не даёт ни одна: <code>h1</code> принадлежит странице, а не
полю внутри неё.
</p>
</div>
</template>