GrRichText

  • editor
  • editor
  • API · 14 props
import { GrRichText } from '@feugene/granularity-editor/components/GrRichText'

Overview

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 set — schema="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 own — extensions 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 text in markdown rather than edit itGrMarkdown
Show ready code with highlighting rather than edit textGrCodeBlock
A set of tags of your own rather than textGrInputTag

Examples 3 each with a live demo and its code

  • The description of a listing

    A listing needs structure, so the schema is article: headings from h2 down, quotes and code on top of the basics, and no h1 in any schema — the page owns it. The field reads GrFormField like any control — label, required, invalid — and name sends the HTML with the form as a hidden field. The footer slot is a zone inside the frame, not a line under it.

    Halden desk · listingShown on the product page under the gallery
    210 characters · headings, lists and quotes keep their look on the page
  • A reply to a ticket

    The minimal schema is also the sanitiser: what it has no node for — an image, a script, a table — does not survive a paste. toolbar="both" keeps the panel on top for the keyboard and adds a bubble at the selection for the mouse. header and footer hold the recipients and the send button inside the frame, and while the reply goes out the field is disabled.

    Ticket #4182 · New address on invoices
    Select text to format it
  • An update for the status page

    output="json" hands the TipTap document out as is: the CMS keeps structure, not markup. The saved note is shown without an editor — markdownFromEditorDocument turns the document into markdown and GrMarkdown reads it, with nothing lost from either schema. Once published the field is readonly: read and copied, not edited.

    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 generated from the package · cannot drift

Props

PropTypedefaultDescription
sizeGrRichTextSize | undefinedundefinedThe size step: the type size of the field and the toolbar buttons. `md` by default; taken from `GrConfigProvider`.
ariaLabelstring | undefinedundefinedThe accessible name of the field; it overrides the label of `GrFormField`.
modelValuev-modelstring | Record<string, unknown> | null | undefinedundefinedThe value: an HTML string or a TipTap document — by `output`. The shape is set by the prop, not by what the user does: the same decision as `valueAdapter` in `granularity-chrono`.
output"html" | "json" | undefined"html"In what shape the value goes outward.
schemaGrRichTextSchemaName | undefined"minimal"A ready-made schema: `minimal` or `article`.
extensionsGrRichTextExtension[] | undefinedundefinedCustom TipTap extensions **in addition** to the schema — mentions, images, a table. They do not try to replace the schema's set: the toolbar is built from it, and a substitution would leave the buttons without commands.
placeholderstring | undefinedundefinedHint in an empty field.
toolbarboolean | "bubble" | "both" | undefinedtrueWhere the formatting buttons live: a bar on top, a bubble menu by the selection, both, or nothing.
idstring | undefinedundefinedOwn `id` for the input area. Unset — taken from `GrFormField`.
namestring | undefinedundefinedName for the native form: the value goes out in a hidden field.
disabledboolean | undefinedfalseThe field is unavailable: the text cannot be edited, the toolbar buttons are disabled. Inherited from `GrFormField`.
readonlyboolean | undefinedfalseThe value is visible and goes into the form, but cannot be edited.
invalidboolean | undefinedfalseThe visual and ARIA state of an error (`aria-invalid`). Inherited from `GrFormField`.
requiredboolean | undefinedfalseA required field (`aria-required`). Inherited from `GrFormField`.

Slots

SlotTypeDescription
headeranyA strip above the input area: a label, a counter, a mode switch. A separate zone rather than a place in the toolbar: the toolbar is built from the schema and moves focus with a rover, while here the content is the consumer's, with its own keyboard.
footeranyA strip below the input area: a character counter, a hint, submit buttons.

Events

EventSignatureDescription
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

MethodsSignatureDescription
editorEditor | nullThe TipTap instance: your own command, your own extension, your own plugin.
focus() => boolean | undefinedPut the cursor in the field. Does nothing until the editor is mounted.
blur() => boolean | undefinedRemove the focus from the field.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrRichTextSize, GrRichTextConfigurableProps, GrRichTextProps } from '@feugene/granularity-editor/components/GrRichText'

Filedist/types/components/GrRichText/config.d.ts

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

Filedist/types/components/GrRichText/grRichTextStyles.d.ts

GrRichTextSizetype
type GrRichTextSize = 'xs' | 'sm' | 'md' | 'lg';
grRichTextSafelistconst
const grRichTextSafelist: string[];

Filedist/types/components/GrRichText/defaults.d.ts

GrRichTextConfigurablePropsinterface
interface GrRichTextConfigurableProps {
    size?: GrRichTextSize;
}

Filedist/types/components/GrRichText/GrRichText.vue.d.ts

GrRichTextPropsinterface
interface GrRichTextProps {
    modelValue?: string | Record<string, unknown> | null;
    output?: 'html' | 'json';
    schema?: GrRichTextSchemaName;
    extensions?: GrRichTextExtension[];
    placeholder?: string;
    toolbar?: boolean | 'bubble' | 'both';
    size?: GrRichTextSize;
    id?: string;
    name?: string;
    disabled?: boolean;
    readonly?: boolean;
    invalid?: boolean;
    required?: boolean;
    ariaLabel?: string;
}
GrRichTextEmitsinterface
interface GrRichTextEmits {
    (e: 'update:modelValue', value: string | Record<string, unknown>): void;
    (e: 'change', value: string | Record<string, unknown>): void;
    (e: 'focus'): void;
    (e: 'blur'): void;
}

Usage from the library prose

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 anchor of the bubble is the rectangle of the selection in viewport coordinates, and scrolling with the wheel does not change the selection. So while the bubble is on, the anchor is recalculated on scroll and resize — scroll is listened for in the capture phase on window, so as to catch the scrolling of a nested area as well, not only of the page. When the selection leaves the visible part of the field or the page, the bubble hides rather than being left hanging over someone else’s content; once it is back, the bubble is beside it again. Scrolling does not bring back a bubble closed with Esc.

The visible part is also cut by the scroll-padding of every scroll area, the page included: that is how a site declares its sticky header (html { scroll-padding-top: 80px }). A selection under the header hides the bubble; right at its edge, where there is no room above the selection, the bubble sits below it rather than on the header. The side is chosen by the field — GrPopover knows nothing of scroll-padding and keeps clear of the viewport edge only.

Whether to show and on which side are two different questions with two different areas. Whether the selection is visible is decided by all clipping ancestors, the field included: a selection that has left the edge of its own scroll hides the bubble. The room for the bubble, though, is the page outside the field: the bubble lives in the top layer, and the field does not clip it. The field’s own scroll (.ProseMirror with overflow-y: auto) and its frame do not count — the bubble lies over the toolbar and the field above the first line, and goes below only when there is no room above the selection on the page, for example at a sticky header. Otherwise it would cover the next line, the one being read.

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.

What was saved is shown without the editor

The field is needed while the text is being edited. For showing what was saved it is not the best choice: ProseMirror requires a DOM, so on the server nothing is printed (see the next section), and in the browser an editable document with plugins, transactions and history is raised for the sake of a single paragraph.

The TipTap document is translated into markdown and given to 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>

There are no losses on this path for any of the schemas. The serialiser knows every node and every mark they are able to produce — down to the line break by Shift+Enter; it serialises a picture as well, although no schema gives one yet. When adding an extension of your own with a new node, a branch in the serialiser will have to be added along with it: what is lost silently is exactly what you added.

It is also fit for storage: output="json" holds the TipTap document, while markdownFromEditorDocument puts markdown into the database — a form that both a person and any other tool will read.

The content is not printed on the server

ProseMirror requires a DOM, so the editor is raised after mounting, and the server markup is a shell with data-allow-mismatch. There is no text in it, but there is room: the host holds a placeholder with the same box the input area will take — --gr-rich-text-min-height and --gr-rich-text-max-height are shared by both, in one rule. A field with min = max does not move on hydration; a value taller than min-height with an open max-height grows to fit the text after mounting — see ../ssr.md.

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:

vue
<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 schema — rebuilds 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.