GrRichText

Package: @feugene/granularity-editorcompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • text the reader will see formatted — a description, an article, an announcement: paragraphs, lists and emphasis carry meaning, and losing them on saving is not allowed;
  • a comment or a note with a minimal setschema="minimal": the style, a link, a list, and nothing that could spoil the page;
  • a form field rather than a screen of its own — the component reads GrFormField, gives its value to the form through a hidden field and obeys disabled, readonly and invalid on a par with the other controls;
  • a set of capabilities of your ownextensions adds TipTap extensions to the schema, and the component stays the same field with its own toolbar and keyboard.

When to take something else

NeedTake
Plain text across several lines, with no markupGrTextarea
A single-line stringGrInput
Show ready code with highlighting rather than edit textGrCodeBlock
A set of tags of your own rather than textGrInputTag

The schema is also the sanitiser

There is no separate sanitiser in the package, and that is not an omission. ProseMirror parses the input by the schema: a node or a mark that is not in it is discarded during the parsing, and on the way out the document is serialised from that same tree. <script>, <iframe> and <img> do not survive a paste — proven by a test rather than promised.

Hence a consequence worth knowing: what is not in the schema will not be in the value either. If an article with pictures was pasted into a field with the minimal schema, the text remains. That is a deliberate trade: a field accepting arbitrary markup breaks the layout of the page it will later be shown on.

Two ready schemas

minimal is the style, a link, a list. article adds structure: headings, a quote, a code block.

Neither gives a first-level heading. There is one h1 on a page and it belongs to the page rather than to an input field inside it; an editor that lets a second one be inserted breaks the structure of the document for someone who was simply typing text.

The toolbar is assembled from the schema rather than written as markup

The buttons are built from the list of actions of the schema itself. Write them by hand and the very first edit of the schema would diverge from the panel silently: the button remained, and the command behind it is no longer there.

The panel wraps by whole groups rather than button by button. A flex-wrap over individual buttons would tear the row where the room ran out and would separate buttons of one meaning: “Heading” on one line, “Subheading” on another, and without a separator between them. The wrapper of a group does not allow that — in a narrow field the lines read as the same blocks as one row.

The toolbar is a role="toolbar" with one Tab stop: inside, the arrows do the walking. Otherwise reaching the text itself would take a dozen presses — “article” has ten buttons. The active format is announced with aria-pressed rather than only by the highlighting: the highlighting does not exist for a screen reader.

The bubble holds on the focus in the text

toolbar="bubble" puts the panel at the selection, "both" both above and at the selection.

The bubble lives exactly while there is a non-empty selection and focus in the field: the loss of focus is enough for it to go out. Hence two non-obvious consequences, and both sit in the code:

  • the panel does not take the focus on opening — otherwise it would put itself out in the same frame in which it opened;
  • a button of the bubble cancels mousedown. Without that the focus would go to the button and the bubble would disappear under the cursor after the very first format — there would be nothing to apply a second one with. The panel above does not need that: it does not depend on the focus.

The buttons of the bubble are always of the smallest step, regardless of the size of the field. The panel above lives in the frame of the field and grows with it, while the bubble hangs over the text that is being read, and there the smallest area that gives a press target of 28×28 is appropriate — above the twenty-four required by WCAG 2.2. There is an arithmetic reason as well: the panel of a popover is limited in width, and a dozen buttons of the size of the field did not fit into it.

Esc closes the bubble, and a click outside does not: click would have to be listened for, and it is exactly what a drag of the selection ends with, so the panel would close at the very moment it is supposed to appear.

The keyboard path to the formats in this mode is the hotkeys: the panel at the selection is not a Tab stop. If both roads are needed — toolbar="both".

The shape of the value is set with a prop

output="html" (the default) is a string of markup; output="json" is a TipTap document. The same device as valueAdapter in granularity-chrono: the shape of the value is chosen by the consumer rather than by the behaviour of the user.

Into a native form the value goes as a string in any mode: a hidden field cannot do objects.

The content is not printed on the server

ProseMirror requires a DOM, so the editor is raised after mounting, and the server markup is an empty shell with data-allow-mismatch.

There is deliberately no v-html in the package: printing someone else’s markup for the sake of the first frame would mean introducing the single XSS surface exactly where the data comes from the user. A field is input rather than publication.

Extensions of your own

extensions adds TipTap extensions to the schema rather than replacing it:

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

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

The ready extensions are listed in the TipTap catalogue, and one of your own is written by the guide. The package does not wrap them: what was handed over is what the editor gets.

A change of the set — like a change of schemarebuilds the editor: a ProseMirror schema is immutable, and both the document and the commands are derived from it. The text is carried over as markup and goes through the parsing again, so a node that is not in the new schema is discarded — the same rule as on a paste.

The toolbar will not show a button for an extension of your own: it is built from the schema, and a button with no command behind it would be a deception. A button of your own is put in with the #action-<key> slot or with a panel of your own over the instance from defineExpose.

Limits

  • not a CMS: no pictures, no mentions, no tables in this release. A TipTap extension is added by the consumer themselves through extensions, and the instance of the editor is given away by the component through defineExpose. The toolbar will not show a button for an extension of your own: it is built from the schema rather than from the set of extensions;
  • no collaborative editing: simultaneous editing by two people requires a transport and conflict resolution, and that is not the task of an input field;
  • no markdown viewer: showing saved text is covered by a separate component, and it has not been written yet.

Install

npm i @feugene/granularity-editor

Import

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

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 5

Basic

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

Extensionsdepends on the showcase environment
<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

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

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>&lt;script&gt;</code> и
      <code>&lt;iframe&gt;</code>. Слева осталась только строчная разметка, справа — ещё заголовок и
      цитата. Скрипта и фрейма нет нигде: <strong>узлы вне схемы не переживают разбора</strong>.
    </p>

    <p class="showcase-demo-text text-sm">
      Отдельного санитайзера в пакете поэтому нет. Разбор идёт по схеме, а на выход документ
      сериализуется из того же дерева — очистка получается тем же механизмом, ради которого редактор
      и выбран. Обратная сторона: чего нет в схеме, того не будет и в значении.
    </p>
  </div>
</template>

Toolbar

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>

Component documentationAll components